mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 13:34:17 +02:00
Add task_meta parameter to read_resource() for explicit task control (#2750)
This commit is contained in:
parent
bf1c222e20
commit
fc19f1f8de
15 changed files with 674 additions and 172 deletions
|
|
@ -5,7 +5,8 @@ from __future__ import annotations
|
|||
import base64
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Annotated, Any, ClassVar
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, overload
|
||||
|
||||
import mcp.types
|
||||
|
||||
|
|
@ -27,7 +28,7 @@ from pydantic import (
|
|||
from typing_extensions import Self
|
||||
|
||||
from fastmcp.server.dependencies import without_injected_parameters
|
||||
from fastmcp.server.tasks.config import TaskConfig
|
||||
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.types import get_fn_name
|
||||
|
||||
|
|
@ -300,23 +301,42 @@ class Resource(FastMCPComponent):
|
|||
# ResourceResult.__init__ handles all normalization
|
||||
return ResourceResult(raw_value)
|
||||
|
||||
async def _read(self) -> ResourceResult | mcp.types.CreateTaskResult:
|
||||
@overload
|
||||
async def _read(self, task_meta: None = None) -> ResourceResult: ...
|
||||
|
||||
@overload
|
||||
async def _read(self, task_meta: TaskMeta) -> mcp.types.CreateTaskResult: ...
|
||||
|
||||
async def _read(
|
||||
self, task_meta: TaskMeta | None = None
|
||||
) -> ResourceResult | mcp.types.CreateTaskResult:
|
||||
"""Server entry point that handles task routing.
|
||||
|
||||
This allows ANY Resource subclass to support background execution by setting
|
||||
task_config.mode to "supported" or "required". The server calls this
|
||||
method instead of read() directly.
|
||||
|
||||
Args:
|
||||
task_meta: If provided, execute as a background task and return
|
||||
CreateTaskResult. If None (default), execute synchronously and
|
||||
return ResourceResult.
|
||||
|
||||
Returns:
|
||||
ResourceResult when task_meta is None.
|
||||
CreateTaskResult when task_meta is provided.
|
||||
|
||||
Subclasses can override this to customize task routing behavior.
|
||||
For example, FastMCPProviderResource overrides to delegate to child
|
||||
middleware without submitting to Docket.
|
||||
"""
|
||||
from fastmcp.server.dependencies import _docket_fn_key
|
||||
from fastmcp.server.tasks.routing import check_background_task
|
||||
|
||||
key = _docket_fn_key.get() or self.key
|
||||
# Enrich task_meta with fn_key if not already set (fallback for programmatic API)
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = replace(task_meta, fn_key=self.key)
|
||||
|
||||
task_result = await check_background_task(
|
||||
component=self, task_type="resource", key=key
|
||||
component=self, task_type="resource", arguments=None, task_meta=task_meta
|
||||
)
|
||||
if task_result:
|
||||
return task_result
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ from __future__ import annotations
|
|||
import inspect
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, overload
|
||||
from urllib.parse import parse_qs, unquote
|
||||
|
||||
import mcp.types
|
||||
|
|
@ -23,7 +24,7 @@ from pydantic import (
|
|||
|
||||
from fastmcp.resources.resource import Resource, ResourceResult
|
||||
from fastmcp.server.dependencies import without_injected_parameters
|
||||
from fastmcp.server.tasks.config import TaskConfig
|
||||
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
from fastmcp.utilities.json_schema import compress_schema
|
||||
from fastmcp.utilities.types import get_cached_typeadapter
|
||||
|
|
@ -177,8 +178,18 @@ class ResourceTemplate(FastMCPComponent):
|
|||
# ResourceResult.__init__ handles all normalization
|
||||
return ResourceResult(raw_value)
|
||||
|
||||
@overload
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any]
|
||||
self, uri: str, params: dict[str, Any], task_meta: None = None
|
||||
) -> ResourceResult: ...
|
||||
|
||||
@overload
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: TaskMeta
|
||||
) -> mcp.types.CreateTaskResult: ...
|
||||
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None
|
||||
) -> ResourceResult | mcp.types.CreateTaskResult:
|
||||
"""Server entry point that handles task routing.
|
||||
|
||||
|
|
@ -186,19 +197,29 @@ class ResourceTemplate(FastMCPComponent):
|
|||
by setting task_config.mode to "supported" or "required". The server calls
|
||||
this method instead of create_resource()/read() directly.
|
||||
|
||||
Args:
|
||||
uri: The concrete URI being read
|
||||
params: Template parameters extracted from the URI
|
||||
task_meta: If provided, execute as a background task and return
|
||||
CreateTaskResult. If None (default), execute synchronously and
|
||||
return ResourceResult.
|
||||
|
||||
Returns:
|
||||
ResourceResult when task_meta is None.
|
||||
CreateTaskResult when task_meta is provided.
|
||||
|
||||
Subclasses can override this to customize task routing behavior.
|
||||
For example, FastMCPProviderResourceTemplate overrides to delegate to child
|
||||
middleware without submitting to Docket.
|
||||
"""
|
||||
from fastmcp.server.dependencies import _docket_fn_key
|
||||
from fastmcp.server.tasks.routing import check_background_task
|
||||
|
||||
# Templates need pattern check: only use contextvar if it contains '{'
|
||||
key = _docket_fn_key.get()
|
||||
if not key or "{" not in key:
|
||||
key = self.key
|
||||
# Enrich task_meta with fn_key if not already set (fallback for programmatic API)
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = replace(task_meta, fn_key=self.key)
|
||||
|
||||
task_result = await check_background_task(
|
||||
component=self, task_type="template", key=key, arguments=params
|
||||
component=self, task_type="template", arguments=params, task_meta=task_meta
|
||||
)
|
||||
if task_result:
|
||||
return task_result
|
||||
|
|
@ -294,23 +315,43 @@ class FunctionResourceTemplate(ResourceTemplate):
|
|||
|
||||
fn: Callable[..., Any]
|
||||
|
||||
@overload
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any]
|
||||
self, uri: str, params: dict[str, Any], task_meta: None = None
|
||||
) -> ResourceResult: ...
|
||||
|
||||
@overload
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: TaskMeta
|
||||
) -> mcp.types.CreateTaskResult: ...
|
||||
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None
|
||||
) -> ResourceResult | mcp.types.CreateTaskResult:
|
||||
"""Optimized server entry point that skips ephemeral resource creation.
|
||||
|
||||
For FunctionResourceTemplate, we can call read() directly instead of
|
||||
creating a temporary resource, which is more efficient.
|
||||
|
||||
Args:
|
||||
uri: The concrete URI being read
|
||||
params: Template parameters extracted from the URI
|
||||
task_meta: If provided, execute as a background task and return
|
||||
CreateTaskResult. If None (default), execute synchronously and
|
||||
return ResourceResult.
|
||||
|
||||
Returns:
|
||||
ResourceResult when task_meta is None.
|
||||
CreateTaskResult when task_meta is provided.
|
||||
"""
|
||||
from fastmcp.server.dependencies import _docket_fn_key
|
||||
from fastmcp.server.tasks.routing import check_background_task
|
||||
|
||||
# Templates need pattern check: only use contextvar if it contains '{'
|
||||
key = _docket_fn_key.get()
|
||||
if not key or "{" not in key:
|
||||
key = self.key
|
||||
# Enrich task_meta with fn_key if not already set (fallback for programmatic API)
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = replace(task_meta, fn_key=self.key)
|
||||
|
||||
task_result = await check_background_task(
|
||||
component=self, task_type="template", key=key, arguments=params
|
||||
component=self, task_type="template", arguments=params, task_meta=task_meta
|
||||
)
|
||||
if task_result:
|
||||
return task_result
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ from __future__ import annotations
|
|||
import re
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING, Any, overload
|
||||
|
||||
import mcp.types
|
||||
from mcp.types import AnyUrl
|
||||
|
|
@ -85,6 +86,20 @@ class FastMCPProviderTool(Tool):
|
|||
task_config=tool.task_config,
|
||||
)
|
||||
|
||||
@overload
|
||||
async def _run(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
task_meta: None = None,
|
||||
) -> ToolResult: ...
|
||||
|
||||
@overload
|
||||
async def _run(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
task_meta: TaskMeta,
|
||||
) -> mcp.types.CreateTaskResult: ...
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
|
|
@ -94,7 +109,15 @@ class FastMCPProviderTool(Tool):
|
|||
|
||||
Passes task_meta through to the child server so it can handle
|
||||
backgrounding appropriately.
|
||||
|
||||
Enriches fn_key with self.key (the parent's namespaced tool name) so that
|
||||
when the child tool delegates to Docket, it uses the correct lookup key
|
||||
that was registered via get_tasks().
|
||||
"""
|
||||
# Enrich fn_key with parent's key before delegating to child
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = replace(task_meta, fn_key=self.key)
|
||||
|
||||
return await self._server.call_tool(
|
||||
self._original_name, arguments, task_meta=task_meta
|
||||
)
|
||||
|
|
@ -148,19 +171,29 @@ class FastMCPProviderResource(Resource):
|
|||
task_config=resource.task_config,
|
||||
)
|
||||
|
||||
async def _read(self) -> ResourceResult | mcp.types.CreateTaskResult:
|
||||
"""Skip task routing - delegate to child server's read_resource().
|
||||
@overload
|
||||
async def _read(self, task_meta: None = None) -> ResourceResult: ...
|
||||
|
||||
The actual underlying resource will check _task_metadata contextvar and
|
||||
submit to Docket if appropriate. This wrapper just passes through.
|
||||
@overload
|
||||
async def _read(self, task_meta: TaskMeta) -> mcp.types.CreateTaskResult: ...
|
||||
|
||||
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)
|
||||
which is what the function is registered under in Docket. All provider
|
||||
layers pass this through unchanged so the eventual resource._read()
|
||||
uses the correct Docket lookup key.
|
||||
async def _read(
|
||||
self, task_meta: TaskMeta | None = None
|
||||
) -> ResourceResult | mcp.types.CreateTaskResult:
|
||||
"""Delegate to child server's read_resource() with task_meta.
|
||||
|
||||
Passes task_meta through to the child server so it can handle
|
||||
backgrounding appropriately.
|
||||
|
||||
Enriches fn_key with self.key (the parent's namespaced URI) so that
|
||||
when the child resource delegates to Docket, it uses the correct
|
||||
lookup key that was registered via get_tasks().
|
||||
"""
|
||||
return await self._server.read_resource(self._original_uri)
|
||||
# Enrich fn_key with parent's URI before delegating to child
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = TaskMeta(ttl=task_meta.ttl, fn_key=self.key)
|
||||
|
||||
return await self._server.read_resource(self._original_uri, task_meta=task_meta)
|
||||
|
||||
|
||||
class FastMCPProviderPrompt(Prompt):
|
||||
|
|
@ -277,41 +310,36 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
|
|||
mime_type=self.mime_type,
|
||||
)
|
||||
|
||||
@overload
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any]
|
||||
self, uri: str, params: dict[str, Any], task_meta: None = None
|
||||
) -> ResourceResult: ...
|
||||
|
||||
@overload
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: TaskMeta
|
||||
) -> mcp.types.CreateTaskResult: ...
|
||||
|
||||
async def _read(
|
||||
self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None
|
||||
) -> ResourceResult | mcp.types.CreateTaskResult:
|
||||
"""Delegate to child server's read_resource().
|
||||
"""Delegate to child server's read_resource() with task_meta.
|
||||
|
||||
Skips task routing at this layer - the child's template._read() will
|
||||
check _task_metadata contextvar and submit to Docket if appropriate.
|
||||
Passes task_meta through to the child server so it can handle
|
||||
backgrounding appropriately.
|
||||
|
||||
Sets _docket_fn_key to self.uri_template (the transformed pattern) so that
|
||||
when the child template's _read() submits to Docket, it uses the correct
|
||||
key that matches what was registered via TransformingProvider.get_tasks().
|
||||
|
||||
Only sets _docket_fn_key if not already set - in nested mounts, the
|
||||
outermost wrapper sets the key and inner wrappers preserve it.
|
||||
Enriches fn_key with self.key (the parent's namespaced template pattern)
|
||||
so that when the child template delegates to Docket, it uses the correct
|
||||
lookup key that was registered via get_tasks().
|
||||
"""
|
||||
from fastmcp.server.dependencies import _docket_fn_key
|
||||
|
||||
# Expand the original template with params to get internal URI
|
||||
original_uri = _expand_uri_template(self._original_uri_template or "", params)
|
||||
|
||||
# Set _docket_fn_key to the template pattern, but only if the current
|
||||
# value isn't already a template pattern (contains '{').
|
||||
# - Server sets concrete URI (e.g., "item://c/gc/42") - no '{', override it
|
||||
# - Outer wrapper sets pattern (e.g., "item://c/gc/{id}") - has '{', keep it
|
||||
# In nested mounts (parent→child→grandchild), the outermost wrapper
|
||||
# has the fully-transformed pattern that matches Docket registration.
|
||||
existing_key = _docket_fn_key.get()
|
||||
key_token = None
|
||||
if not existing_key or "{" not in existing_key:
|
||||
key_token = _docket_fn_key.set(self.key)
|
||||
try:
|
||||
return await self._server.read_resource(original_uri)
|
||||
finally:
|
||||
if key_token is not None:
|
||||
_docket_fn_key.reset(key_token)
|
||||
# Enrich fn_key with parent's namespaced template pattern before delegating
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = replace(task_meta, fn_key=self.key)
|
||||
|
||||
return await self._server.read_resource(original_uri, task_meta=task_meta)
|
||||
|
||||
async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult:
|
||||
"""Read the resource content for background task execution.
|
||||
|
|
|
|||
|
|
@ -1176,9 +1176,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
ToolError: If tool execution fails
|
||||
ValidationError: If arguments fail validation
|
||||
"""
|
||||
# Enrich task_meta with fn_key if task execution requested
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = TaskMeta(ttl=task_meta.ttl, fn_key=Tool.make_key(name))
|
||||
# Note: fn_key enrichment happens in Tool._run(), not here,
|
||||
# so that provider wrappers can enrich with their namespaced key.
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=self) as ctx:
|
||||
if run_middleware:
|
||||
|
|
@ -1221,11 +1220,30 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
raise NotFoundError(f"Unknown tool: {name!r}")
|
||||
|
||||
@overload
|
||||
async def read_resource(
|
||||
self,
|
||||
uri: str,
|
||||
*,
|
||||
run_middleware: bool = True,
|
||||
task_meta: None = None,
|
||||
) -> ResourceResult: ...
|
||||
|
||||
@overload
|
||||
async def read_resource(
|
||||
self,
|
||||
uri: str,
|
||||
*,
|
||||
run_middleware: bool = True,
|
||||
task_meta: TaskMeta,
|
||||
) -> mcp.types.CreateTaskResult: ...
|
||||
|
||||
async def read_resource(
|
||||
self,
|
||||
uri: str,
|
||||
*,
|
||||
run_middleware: bool = True,
|
||||
task_meta: TaskMeta | None = None,
|
||||
) -> ResourceResult | mcp.types.CreateTaskResult:
|
||||
"""Read a resource by URI.
|
||||
|
||||
|
|
@ -1236,15 +1254,23 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
uri: The resource URI
|
||||
run_middleware: If True (default), apply the middleware chain.
|
||||
Set to False when called from middleware to avoid re-applying.
|
||||
task_meta: If provided, execute as a background task and return
|
||||
CreateTaskResult. If None (default), execute synchronously and
|
||||
return ResourceResult.
|
||||
|
||||
Returns:
|
||||
ResourceResult with contents.
|
||||
May return CreateTaskResult if called in MCP context with task metadata.
|
||||
ResourceResult when task_meta is None.
|
||||
CreateTaskResult when task_meta is provided.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If resource not found or disabled
|
||||
ResourceError: If resource read fails
|
||||
"""
|
||||
# Note: fn_key enrichment happens in each component's _read() method,
|
||||
# not here, because resources and templates use different key formats:
|
||||
# - Resources use Resource.make_key(uri) for the concrete URI
|
||||
# - Templates use self.key which is the template pattern
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=self) as ctx:
|
||||
if run_middleware:
|
||||
uri_param = AnyUrl(uri)
|
||||
|
|
@ -1255,16 +1281,14 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
method="resources/read",
|
||||
fastmcp_context=ctx,
|
||||
)
|
||||
result = await self._run_middleware(
|
||||
return await self._run_middleware(
|
||||
context=mw_context,
|
||||
call_next=lambda context: self.read_resource(
|
||||
str(context.message.uri),
|
||||
run_middleware=False,
|
||||
task_meta=task_meta,
|
||||
),
|
||||
)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return result
|
||||
|
||||
# Core logic: find and read resource
|
||||
# First pass: try concrete resources from all providers
|
||||
|
|
@ -1272,10 +1296,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
resource = await provider.get_resource(uri)
|
||||
if resource is not None and self._is_component_enabled(resource):
|
||||
try:
|
||||
result = await resource._read()
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return result
|
||||
return await resource._read(task_meta=task_meta)
|
||||
except (FastMCPError, McpError):
|
||||
logger.exception(f"Error reading resource {uri!r}")
|
||||
raise
|
||||
|
|
@ -1296,10 +1317,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
params = template.matches(uri)
|
||||
if params is not None:
|
||||
try:
|
||||
result = await template._read(uri, params)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return result
|
||||
return await template._read(
|
||||
uri, params, task_meta=task_meta
|
||||
)
|
||||
except (FastMCPError, McpError):
|
||||
logger.exception(f"Error reading resource {uri!r}")
|
||||
raise
|
||||
|
|
@ -1536,17 +1556,18 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
)
|
||||
|
||||
try:
|
||||
# Extract SEP-1686 task metadata from request context
|
||||
# Extract SEP-1686 task metadata from request context.
|
||||
# NOTE: fn_key is NOT set here. The component (or provider wrapper) will
|
||||
# enrich fn_key with self.key. For mounted servers, the provider wrapper
|
||||
# sets fn_key to the parent's namespaced key, ensuring Docket finds the
|
||||
# correctly registered function.
|
||||
task_meta: TaskMeta | None = None
|
||||
try:
|
||||
ctx = self._mcp_server.request_context
|
||||
if ctx.experimental.is_task:
|
||||
mcp_task_meta = ctx.experimental.task_metadata
|
||||
task_meta_dict = mcp_task_meta.model_dump(exclude_none=True)
|
||||
task_meta = TaskMeta(
|
||||
ttl=task_meta_dict.get("ttl"),
|
||||
fn_key=Tool.make_key(key),
|
||||
)
|
||||
task_meta = TaskMeta(ttl=task_meta_dict.get("ttl"))
|
||||
except (AttributeError, LookupError):
|
||||
pass
|
||||
|
||||
|
|
@ -1566,8 +1587,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
) -> mcp.types.ReadResourceResult | mcp.types.CreateTaskResult:
|
||||
"""Handle MCP 'readResource' requests.
|
||||
|
||||
Sets task metadata contextvar and calls read_resource(). The resource's
|
||||
_read() method handles the backgrounding decision.
|
||||
Extracts task metadata from MCP request context and passes it explicitly
|
||||
to read_resource(). The resource's _read() method handles the backgrounding
|
||||
decision, ensuring middleware runs before Docket.
|
||||
|
||||
Args:
|
||||
uri: The resource URI
|
||||
|
|
@ -1575,33 +1597,31 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
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:
|
||||
# Extract SEP-1686 task metadata from request context
|
||||
task_meta_dict: dict[str, Any] | None = None
|
||||
# Extract SEP-1686 task metadata from request context.
|
||||
# NOTE: fn_key is NOT set here for resources because we don't know yet
|
||||
# if the URI will be handled by a direct resource or a template. Templates
|
||||
# need their pattern-based key for Docket lookup, not the concrete URI.
|
||||
# The component (or provider wrapper) will enrich fn_key with self.key.
|
||||
# For mounted servers, the provider wrapper sets fn_key to the parent's
|
||||
# namespaced key, ensuring Docket finds the correctly registered function.
|
||||
task_meta: TaskMeta | 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)
|
||||
mcp_task_meta = ctx.experimental.task_metadata
|
||||
task_meta_dict = mcp_task_meta.model_dump(exclude_none=True)
|
||||
task_meta = TaskMeta(ttl=task_meta_dict.get("ttl"))
|
||||
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))
|
||||
result = await self.read_resource(str(uri), task_meta=task_meta)
|
||||
|
||||
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)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return result.to_mcp_result(uri)
|
||||
except DisabledError as e:
|
||||
raise NotFoundError(f"Unknown resource: {str(uri)!r}") from e
|
||||
except NotFoundError:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
import inspect
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Annotated,
|
||||
|
|
@ -12,6 +12,7 @@ from typing import (
|
|||
Generic,
|
||||
TypeAlias,
|
||||
get_type_hints,
|
||||
overload,
|
||||
)
|
||||
|
||||
import mcp.types
|
||||
|
|
@ -277,6 +278,20 @@ class Tool(FastMCPComponent):
|
|||
structured_content={"result": structured} if wrap_result else structured,
|
||||
)
|
||||
|
||||
@overload
|
||||
async def _run(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
task_meta: None = None,
|
||||
) -> ToolResult: ...
|
||||
|
||||
@overload
|
||||
async def _run(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
task_meta: TaskMeta,
|
||||
) -> mcp.types.CreateTaskResult: ...
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
|
|
@ -290,8 +305,13 @@ class Tool(FastMCPComponent):
|
|||
|
||||
Args:
|
||||
arguments: Tool arguments
|
||||
task_meta: If provided, execute as background task. If None, execute
|
||||
synchronously.
|
||||
task_meta: If provided, execute as background task and return
|
||||
CreateTaskResult. If None (default), execute synchronously and
|
||||
return ToolResult.
|
||||
|
||||
Returns:
|
||||
ToolResult when task_meta is None.
|
||||
CreateTaskResult when task_meta is provided.
|
||||
|
||||
Subclasses can override this to customize task routing behavior.
|
||||
For example, FastMCPProviderTool overrides to delegate to child
|
||||
|
|
@ -299,6 +319,10 @@ class Tool(FastMCPComponent):
|
|||
"""
|
||||
from fastmcp.server.tasks.routing import check_background_task
|
||||
|
||||
# Enrich task_meta with fn_key if not already set (fallback for programmatic API)
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = replace(task_meta, fn_key=self.key)
|
||||
|
||||
task_result = await check_background_task(
|
||||
component=self,
|
||||
task_type="tool",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import pytest
|
||||
from pydantic import AnyUrl, BaseModel
|
||||
|
||||
from fastmcp.resources.resource import FunctionResource, ResourceContent, ResourceResult
|
||||
from fastmcp.resources.resource import FunctionResource, ResourceContent
|
||||
|
||||
|
||||
class TestFunctionResource:
|
||||
|
|
@ -42,7 +42,6 @@ class TestFunctionResource:
|
|||
|
||||
# _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"
|
||||
|
|
@ -64,7 +63,6 @@ class TestFunctionResource:
|
|||
|
||||
# _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):
|
||||
|
|
@ -159,7 +157,6 @@ class TestFunctionResource:
|
|||
|
||||
# _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"
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from pydantic import BaseModel
|
|||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.resources import ResourceTemplate
|
||||
from fastmcp.resources.resource import FunctionResource, ResourceResult
|
||||
from fastmcp.resources.resource import FunctionResource
|
||||
from fastmcp.resources.template import match_uri_template
|
||||
|
||||
|
||||
|
|
@ -188,7 +188,6 @@ class TestResourceTemplate:
|
|||
|
||||
# _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"
|
||||
|
||||
|
|
|
|||
|
|
@ -303,7 +303,6 @@ class TestResourceConvertResult:
|
|||
|
||||
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"
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ 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
|
||||
|
|
@ -514,7 +513,6 @@ class TestApplyMiddlewareParameter:
|
|||
|
||||
result = await server.read_resource("resource://test")
|
||||
|
||||
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)
|
||||
|
|
@ -532,7 +530,6 @@ class TestApplyMiddlewareParameter:
|
|||
|
||||
result = await server.read_resource("resource://test", run_middleware=False)
|
||||
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert len(result.contents) == 1
|
||||
assert result.contents[0].content == "test content"
|
||||
# Middleware should not have been called
|
||||
|
|
@ -551,7 +548,6 @@ class TestApplyMiddlewareParameter:
|
|||
|
||||
result = await server.read_resource("resource://items/42", run_middleware=False)
|
||||
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert len(result.contents) == 1
|
||||
assert result.contents[0].content == "item 42"
|
||||
assert len(recording.calls) == 0
|
||||
|
|
|
|||
|
|
@ -82,7 +82,6 @@ class TestResourceTemplates:
|
|||
return f"Data for {name}"
|
||||
|
||||
result = await mcp.read_resource("resource://test/data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Data for test"
|
||||
|
||||
async def test_resource_mismatched_params(self):
|
||||
|
|
@ -107,7 +106,6 @@ class TestResourceTemplates:
|
|||
return f"Data for {org}/{repo}"
|
||||
|
||||
result = await mcp.read_resource("resource://cursor/fastmcp/data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Data for cursor/fastmcp"
|
||||
|
||||
async def test_resource_multiple_mismatched_params(self):
|
||||
|
|
@ -132,7 +130,6 @@ class TestResourceTemplates:
|
|||
return str(sum(int(v) for v in kwargs.values()))
|
||||
|
||||
result = await mcp.read_resource("test://1/2/3")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "6"
|
||||
|
||||
async def test_template_with_default_params(self):
|
||||
|
|
@ -148,11 +145,9 @@ class TestResourceTemplates:
|
|||
assert templates[0].uri_template == "math://add/{x}"
|
||||
|
||||
result = await mcp.read_resource("math://add/5")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "15"
|
||||
|
||||
result2 = await mcp.read_resource("math://add/7")
|
||||
assert isinstance(result2, ResourceResult)
|
||||
assert result2.contents[0].content == "17"
|
||||
|
||||
async def test_template_to_resource_conversion(self):
|
||||
|
|
@ -168,7 +163,6 @@ class TestResourceTemplates:
|
|||
assert templates[0].uri_template == "resource://{name}/data"
|
||||
|
||||
result = await mcp.read_resource("resource://test/data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Data for test"
|
||||
|
||||
async def test_template_decorator_with_tags(self):
|
||||
|
|
@ -190,7 +184,6 @@ class TestResourceTemplates:
|
|||
return f"Template resource: {param}"
|
||||
|
||||
result = await mcp.read_resource("resource://test/data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Template resource: test/data"
|
||||
|
||||
async def test_template_with_query_params(self):
|
||||
|
|
@ -202,15 +195,12 @@ class TestResourceTemplates:
|
|||
return f"id={id}, format={format}, limit={limit}"
|
||||
|
||||
result = await mcp.read_resource("data://123")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "id=123, format=json, limit=10"
|
||||
|
||||
result = await mcp.read_resource("data://123?format=xml")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "id=123, format=xml, limit=10"
|
||||
|
||||
result = await mcp.read_resource("data://123?format=csv&limit=50")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "id=123, format=csv, limit=50"
|
||||
|
||||
async def test_templates_match_in_order_of_definition(self):
|
||||
|
|
@ -226,11 +216,9 @@ class TestResourceTemplates:
|
|||
return f"Template resource 2: {x}/{y}"
|
||||
|
||||
result = await mcp.read_resource("resource://a/b/c")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Template resource 1: a/b/c"
|
||||
|
||||
result = await mcp.read_resource("resource://a/b")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Template resource 1: a/b"
|
||||
|
||||
async def test_templates_shadow_each_other_reorder(self):
|
||||
|
|
@ -246,11 +234,9 @@ class TestResourceTemplates:
|
|||
return f"Template resource 2: {param}"
|
||||
|
||||
result = await mcp.read_resource("resource://a/b/c")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Template resource 2: a/b/c"
|
||||
|
||||
result = await mcp.read_resource("resource://a/b")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Template resource 1: a/b"
|
||||
|
||||
async def test_resource_template_with_annotations(self):
|
||||
|
|
@ -324,7 +310,6 @@ class TestResourceDecorator:
|
|||
return "Hello, world!"
|
||||
|
||||
result = await mcp.read_resource("resource://data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Hello, world!"
|
||||
|
||||
async def test_resource_decorator_incorrect_usage(self):
|
||||
|
|
@ -350,7 +335,6 @@ class TestResourceDecorator:
|
|||
assert resources[0].name == "custom-data"
|
||||
|
||||
result = await mcp.read_resource("resource://data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Hello, world!"
|
||||
|
||||
async def test_resource_decorator_with_description(self):
|
||||
|
|
@ -395,7 +379,6 @@ class TestResourceDecorator:
|
|||
)
|
||||
|
||||
result = await mcp.read_resource("resource://data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "My prefix: Hello, world!"
|
||||
|
||||
async def test_resource_decorator_classmethod(self):
|
||||
|
|
@ -415,7 +398,6 @@ class TestResourceDecorator:
|
|||
)
|
||||
|
||||
result = await mcp.read_resource("resource://data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Class prefix: Hello, world!"
|
||||
|
||||
async def test_resource_decorator_classmethod_error(self):
|
||||
|
|
@ -439,7 +421,6 @@ class TestResourceDecorator:
|
|||
return "Static Hello, world!"
|
||||
|
||||
result = await mcp.read_resource("resource://data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Static Hello, world!"
|
||||
|
||||
async def test_resource_decorator_async_function(self):
|
||||
|
|
@ -450,7 +431,6 @@ class TestResourceDecorator:
|
|||
return "Async Hello, world!"
|
||||
|
||||
result = await mcp.read_resource("resource://data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Async Hello, world!"
|
||||
|
||||
async def test_resource_decorator_staticmethod_order(self):
|
||||
|
|
@ -464,7 +444,6 @@ class TestResourceDecorator:
|
|||
return "Static Hello, world!"
|
||||
|
||||
result = await mcp.read_resource("resource://data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Static Hello, world!"
|
||||
|
||||
async def test_resource_decorator_with_meta(self):
|
||||
|
|
@ -499,7 +478,6 @@ class TestResourceDecorator:
|
|||
)
|
||||
|
||||
result = await mcp.read_resource("resource://widget")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert len(result.contents) == 1
|
||||
assert result.contents[0].content == "<widget>content</widget>"
|
||||
assert result.contents[0].mime_type == "text/html"
|
||||
|
|
@ -521,7 +499,6 @@ class TestResourceDecorator:
|
|||
)
|
||||
|
||||
result = await mcp.read_resource("resource://binary")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert len(result.contents) == 1
|
||||
assert result.contents[0].content == b"\x00\x01\x02"
|
||||
assert result.contents[0].meta == {"encoding": "raw"}
|
||||
|
|
@ -535,7 +512,6 @@ class TestResourceDecorator:
|
|||
return ResourceResult([ResourceContent(content="plain content")])
|
||||
|
||||
result = await mcp.read_resource("resource://plain")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert len(result.contents) == 1
|
||||
assert result.contents[0].content == "plain content"
|
||||
assert result.contents[0].meta is None
|
||||
|
|
@ -555,7 +531,6 @@ class TestTemplateDecorator:
|
|||
assert templates[0].uri_template == "resource://{name}/data"
|
||||
|
||||
result = await mcp.read_resource("resource://test/data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Data for test"
|
||||
|
||||
async def test_template_decorator_incorrect_usage(self):
|
||||
|
|
@ -581,7 +556,6 @@ class TestTemplateDecorator:
|
|||
assert templates[0].name == "custom-template"
|
||||
|
||||
result = await mcp.read_resource("resource://test/data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Data for test"
|
||||
|
||||
async def test_template_decorator_with_description(self):
|
||||
|
|
@ -614,7 +588,6 @@ class TestTemplateDecorator:
|
|||
mcp.add_template(template)
|
||||
|
||||
result = await mcp.read_resource("resource://test/data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "My prefix: Data for test"
|
||||
|
||||
async def test_template_decorator_classmethod(self):
|
||||
|
|
@ -635,7 +608,6 @@ class TestTemplateDecorator:
|
|||
mcp.add_template(template)
|
||||
|
||||
result = await mcp.read_resource("resource://test/data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Class prefix: Data for test"
|
||||
|
||||
async def test_template_decorator_staticmethod(self):
|
||||
|
|
@ -648,7 +620,6 @@ class TestTemplateDecorator:
|
|||
return f"Static Data for {name}"
|
||||
|
||||
result = await mcp.read_resource("resource://test/data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Static Data for test"
|
||||
|
||||
async def test_template_decorator_async_function(self):
|
||||
|
|
@ -659,7 +630,6 @@ class TestTemplateDecorator:
|
|||
return f"Async Data for {name}"
|
||||
|
||||
result = await mcp.read_resource("resource://test/data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Async Data for test"
|
||||
|
||||
async def test_template_decorator_with_tags(self):
|
||||
|
|
|
|||
291
tests/server/tasks/test_resource_task_meta_parameter.py
Normal file
291
tests/server/tasks/test_resource_task_meta_parameter.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"""
|
||||
Tests for the explicit task_meta parameter on FastMCP.read_resource().
|
||||
|
||||
These tests verify that the task_meta parameter provides explicit control
|
||||
over sync vs task execution for resources and resource templates.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.resources.resource import Resource, ResourceResult
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.server.tasks.config import TaskMeta
|
||||
|
||||
|
||||
class TestResourceTaskMetaParameter:
|
||||
"""Tests for task_meta parameter on FastMCP.read_resource()."""
|
||||
|
||||
async def test_task_meta_none_returns_resource_result(self):
|
||||
"""With task_meta=None (default), read_resource returns ResourceResult."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("data://test")
|
||||
async def simple_resource() -> str:
|
||||
return "hello world"
|
||||
|
||||
result = await server.read_resource("data://test")
|
||||
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "hello world"
|
||||
|
||||
async def test_task_meta_none_on_task_enabled_resource_still_returns_result(self):
|
||||
"""Even for task=True resources, task_meta=None returns ResourceResult."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("data://test", task=True)
|
||||
async def task_enabled_resource() -> str:
|
||||
return "hello world"
|
||||
|
||||
# Without task_meta, should execute synchronously
|
||||
result = await server.read_resource("data://test")
|
||||
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "hello world"
|
||||
|
||||
async def test_task_meta_on_forbidden_resource_raises_error(self):
|
||||
"""Providing task_meta to a task=False resource raises McpError."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("data://test", task=False)
|
||||
async def sync_only_resource() -> str:
|
||||
return "hello"
|
||||
|
||||
with pytest.raises(McpError) as exc_info:
|
||||
await server.read_resource("data://test", task_meta=TaskMeta())
|
||||
|
||||
assert "does not support task-augmented execution" in str(exc_info.value)
|
||||
|
||||
async def test_task_meta_fn_key_enrichment_for_resource(self):
|
||||
"""Verify that fn_key enrichment uses Resource.make_key()."""
|
||||
resource_uri = "data://my-resource"
|
||||
expected_key = Resource.make_key(resource_uri)
|
||||
|
||||
assert expected_key == "resource:data://my-resource"
|
||||
|
||||
async def test_task_meta_fn_key_enrichment_for_template(self):
|
||||
"""Verify that fn_key enrichment uses ResourceTemplate.make_key()."""
|
||||
template_pattern = "data://{id}"
|
||||
expected_key = ResourceTemplate.make_key(template_pattern)
|
||||
|
||||
assert expected_key == "template:data://{id}"
|
||||
|
||||
|
||||
class TestResourceTemplateTaslMeta:
|
||||
"""Tests for task_meta with resource templates."""
|
||||
|
||||
async def test_template_task_meta_none_returns_resource_result(self):
|
||||
"""With task_meta=None, template read returns ResourceResult."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("item://{id}")
|
||||
async def get_item(id: str) -> str:
|
||||
return f"Item {id}"
|
||||
|
||||
result = await server.read_resource("item://42")
|
||||
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Item 42"
|
||||
|
||||
async def test_template_task_meta_on_task_enabled_template_returns_result(self):
|
||||
"""Even for task=True templates, task_meta=None returns ResourceResult."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("item://{id}", task=True)
|
||||
async def get_item(id: str) -> str:
|
||||
return f"Item {id}"
|
||||
|
||||
# Without task_meta, should execute synchronously
|
||||
result = await server.read_resource("item://42")
|
||||
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "Item 42"
|
||||
|
||||
async def test_template_task_meta_on_forbidden_template_raises_error(self):
|
||||
"""Providing task_meta to a task=False template raises McpError."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("item://{id}", task=False)
|
||||
async def sync_only_template(id: str) -> str:
|
||||
return f"Item {id}"
|
||||
|
||||
with pytest.raises(McpError) as exc_info:
|
||||
await server.read_resource("item://42", task_meta=TaskMeta())
|
||||
|
||||
assert "does not support task-augmented execution" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestResourceTaskMetaClientIntegration:
|
||||
"""Tests that task_meta works correctly with the Client for resources."""
|
||||
|
||||
async def test_client_read_resource_without_task_gets_immediate_result(self):
|
||||
"""Client without task=True gets immediate result."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("data://test", task=True)
|
||||
async def immediate_resource() -> str:
|
||||
return "hello"
|
||||
|
||||
async with Client(server) as client:
|
||||
result = await client.read_resource("data://test")
|
||||
|
||||
# Should get ReadResourceResult directly
|
||||
assert "hello" in str(result)
|
||||
|
||||
async def test_client_read_resource_with_task_creates_task(self):
|
||||
"""Client with task=True creates a background task."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("data://test", task=True)
|
||||
async def task_resource() -> str:
|
||||
return "hello"
|
||||
|
||||
async with Client(server) as client:
|
||||
from fastmcp.client.tasks import ResourceTask
|
||||
|
||||
task = await client.read_resource("data://test", task=True)
|
||||
|
||||
assert isinstance(task, ResourceTask)
|
||||
|
||||
# Wait for result
|
||||
result = await task.result()
|
||||
assert "hello" in str(result)
|
||||
|
||||
async def test_client_read_template_with_task_creates_task(self):
|
||||
"""Client with task=True on template creates a background task."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("item://{id}", task=True)
|
||||
async def get_item(id: str) -> str:
|
||||
return f"Item {id}"
|
||||
|
||||
async with Client(server) as client:
|
||||
from fastmcp.client.tasks import ResourceTask
|
||||
|
||||
task = await client.read_resource("item://42", task=True)
|
||||
|
||||
assert isinstance(task, ResourceTask)
|
||||
|
||||
# Wait for result
|
||||
result = await task.result()
|
||||
assert "Item 42" in str(result)
|
||||
|
||||
|
||||
class TestResourceTaskMetaDirectServerCall:
|
||||
"""Tests for direct server read_resource calls with task_meta."""
|
||||
|
||||
async def test_resource_can_read_another_resource_with_task(self):
|
||||
"""A resource can read another resource as a background task."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("data://inner", task=True)
|
||||
async def inner_resource() -> str:
|
||||
return "inner data"
|
||||
|
||||
@server.tool
|
||||
async def outer_tool() -> str:
|
||||
# Read inner resource as background task
|
||||
result = await server.read_resource("data://inner", task_meta=TaskMeta())
|
||||
# Should get CreateTaskResult since we provided task_meta
|
||||
return f"Created task: {result.task.taskId}"
|
||||
|
||||
async with Client(server) as client:
|
||||
result = await client.call_tool("outer_tool", {})
|
||||
assert "Created task:" in str(result)
|
||||
|
||||
async def test_resource_can_read_another_resource_synchronously(self):
|
||||
"""A resource can read another resource synchronously (no task_meta)."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("data://inner", task=True)
|
||||
async def inner_resource() -> str:
|
||||
return "inner data"
|
||||
|
||||
@server.tool
|
||||
async def outer_tool() -> str:
|
||||
# Read inner resource synchronously (no task_meta)
|
||||
result = await server.read_resource("data://inner")
|
||||
# Should get ResourceResult directly
|
||||
return f"Got result: {result.contents[0].content}"
|
||||
|
||||
async with Client(server) as client:
|
||||
result = await client.call_tool("outer_tool", {})
|
||||
assert "Got result: inner data" in str(result)
|
||||
|
||||
async def test_resource_can_read_template_with_task(self):
|
||||
"""A tool can read a resource template as a background task."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("item://{id}", task=True)
|
||||
async def get_item(id: str) -> str:
|
||||
return f"Item {id}"
|
||||
|
||||
@server.tool
|
||||
async def outer_tool() -> str:
|
||||
result = await server.read_resource("item://99", task_meta=TaskMeta())
|
||||
return f"Created task: {result.task.taskId}"
|
||||
|
||||
async with Client(server) as client:
|
||||
result = await client.call_tool("outer_tool", {})
|
||||
assert "Created task:" in str(result)
|
||||
|
||||
async def test_resource_can_read_with_custom_ttl(self):
|
||||
"""A tool can read a resource as a background task with custom TTL."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("data://inner", task=True)
|
||||
async def inner_resource() -> str:
|
||||
return "inner data"
|
||||
|
||||
@server.tool
|
||||
async def outer_tool() -> str:
|
||||
custom_ttl = 45000 # 45 seconds
|
||||
result = await server.read_resource(
|
||||
"data://inner", task_meta=TaskMeta(ttl=custom_ttl)
|
||||
)
|
||||
return f"Task TTL: {result.task.ttl}"
|
||||
|
||||
async with Client(server) as client:
|
||||
result = await client.call_tool("outer_tool", {})
|
||||
assert "Task TTL: 45000" in str(result)
|
||||
|
||||
|
||||
class TestResourceTaskMetaTypeNarrowing:
|
||||
"""Tests for type narrowing based on task_meta parameter."""
|
||||
|
||||
async def test_read_resource_without_task_meta_type_is_resource_result(self):
|
||||
"""Calling read_resource without task_meta returns ResourceResult type."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("data://test")
|
||||
async def simple_resource() -> str:
|
||||
return "hello"
|
||||
|
||||
# This should type-check as ResourceResult, not the union type
|
||||
result = await server.read_resource("data://test")
|
||||
|
||||
# No isinstance check needed - type is narrowed by overload
|
||||
content = result.contents[0].content
|
||||
assert content == "hello"
|
||||
|
||||
async def test_read_resource_with_task_meta_type_is_create_task_result(self):
|
||||
"""Calling read_resource with task_meta returns CreateTaskResult type."""
|
||||
server = FastMCP("test")
|
||||
|
||||
@server.resource("data://test", task=True)
|
||||
async def task_resource() -> str:
|
||||
return "hello"
|
||||
|
||||
async with Client(server) as client:
|
||||
# Need to use client to get full task infrastructure
|
||||
from fastmcp.client.tasks import ResourceTask
|
||||
|
||||
task = await client.read_resource("data://test", task=True)
|
||||
assert isinstance(task, ResourceTask)
|
||||
|
||||
# For direct server call, we need the Client context for Docket
|
||||
# This test verifies the overload works via client integration
|
||||
result = await task.result()
|
||||
assert "hello" in str(result)
|
||||
|
|
@ -773,3 +773,147 @@ class TestMiddlewareWithMountedTasks:
|
|||
"parent:after",
|
||||
"grandchild:template",
|
||||
]
|
||||
|
||||
|
||||
class TestMountedTasksWithTaskMetaParameter:
|
||||
"""Test mounted components called directly with task_meta parameter.
|
||||
|
||||
These tests verify the programmatic API where server.call_tool() or
|
||||
server.read_resource() is called with an explicit task_meta parameter,
|
||||
as opposed to using the Client with task=True.
|
||||
|
||||
Direct server calls require a running server context, so we use an outer
|
||||
tool that makes the direct call internally.
|
||||
"""
|
||||
|
||||
async def test_mounted_tool_with_task_meta_creates_task(self):
|
||||
"""Mounted tool called with task_meta returns CreateTaskResult."""
|
||||
from fastmcp.server.tasks.config import TaskMeta
|
||||
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.tool(task=True)
|
||||
async def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, namespace="child")
|
||||
|
||||
@parent.tool
|
||||
async def outer() -> str:
|
||||
# Direct call with task_meta from within server context
|
||||
result = await parent.call_tool(
|
||||
"child_add", {"a": 2, "b": 3}, task_meta=TaskMeta(ttl=300)
|
||||
)
|
||||
assert isinstance(result, mt.CreateTaskResult)
|
||||
return f"task:{result.task.taskId}"
|
||||
|
||||
async with Client(parent) as client:
|
||||
result = await client.call_tool("outer", {})
|
||||
assert "task:" in str(result)
|
||||
|
||||
async def test_mounted_resource_with_task_meta_creates_task(self):
|
||||
"""Mounted resource called with task_meta returns CreateTaskResult."""
|
||||
from fastmcp.server.tasks.config import TaskMeta
|
||||
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.resource("data://info", task=True)
|
||||
async def get_info() -> str:
|
||||
return "child info"
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, namespace="child")
|
||||
|
||||
@parent.tool
|
||||
async def outer() -> str:
|
||||
result = await parent.read_resource(
|
||||
"data://child/info", task_meta=TaskMeta(ttl=300)
|
||||
)
|
||||
assert isinstance(result, mt.CreateTaskResult)
|
||||
return f"task:{result.task.taskId}"
|
||||
|
||||
async with Client(parent) as client:
|
||||
result = await client.call_tool("outer", {})
|
||||
assert "task:" in str(result)
|
||||
|
||||
async def test_mounted_template_with_task_meta_creates_task(self):
|
||||
"""Mounted resource template with task_meta returns CreateTaskResult."""
|
||||
from fastmcp.server.tasks.config import TaskMeta
|
||||
|
||||
child = FastMCP("Child")
|
||||
|
||||
@child.resource("item://{id}", task=True)
|
||||
async def get_item(id: str) -> str:
|
||||
return f"item-{id}"
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, namespace="child")
|
||||
|
||||
@parent.tool
|
||||
async def outer() -> str:
|
||||
result = await parent.read_resource(
|
||||
"item://child/42", task_meta=TaskMeta(ttl=300)
|
||||
)
|
||||
assert isinstance(result, mt.CreateTaskResult)
|
||||
return f"task:{result.task.taskId}"
|
||||
|
||||
async with Client(parent) as client:
|
||||
result = await client.call_tool("outer", {})
|
||||
assert "task:" in str(result)
|
||||
|
||||
async def test_deeply_nested_tool_with_task_meta(self):
|
||||
"""Three-level nested tool works with task_meta."""
|
||||
from fastmcp.server.tasks.config import TaskMeta
|
||||
|
||||
grandchild = FastMCP("Grandchild")
|
||||
|
||||
@grandchild.tool(task=True)
|
||||
async def compute(n: int) -> int:
|
||||
return n * 3
|
||||
|
||||
child = FastMCP("Child")
|
||||
child.mount(grandchild, namespace="gc")
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, namespace="c")
|
||||
|
||||
@parent.tool
|
||||
async def outer() -> str:
|
||||
result = await parent.call_tool(
|
||||
"c_gc_compute", {"n": 7}, task_meta=TaskMeta(ttl=300)
|
||||
)
|
||||
assert isinstance(result, mt.CreateTaskResult)
|
||||
return f"task:{result.task.taskId}"
|
||||
|
||||
async with Client(parent) as client:
|
||||
result = await client.call_tool("outer", {})
|
||||
assert "task:" in str(result)
|
||||
|
||||
async def test_deeply_nested_template_with_task_meta(self):
|
||||
"""Three-level nested template works with task_meta."""
|
||||
from fastmcp.server.tasks.config import TaskMeta
|
||||
|
||||
grandchild = FastMCP("Grandchild")
|
||||
|
||||
@grandchild.resource("doc://{name}", task=True)
|
||||
async def get_doc(name: str) -> str:
|
||||
return f"doc: {name}"
|
||||
|
||||
child = FastMCP("Child")
|
||||
child.mount(grandchild, namespace="gc")
|
||||
|
||||
parent = FastMCP("Parent")
|
||||
parent.mount(child, namespace="c")
|
||||
|
||||
@parent.tool
|
||||
async def outer() -> str:
|
||||
result = await parent.read_resource(
|
||||
"doc://c/gc/readme", task_meta=TaskMeta(ttl=300)
|
||||
)
|
||||
assert isinstance(result, mt.CreateTaskResult)
|
||||
return f"task:{result.task.taskId}"
|
||||
|
||||
async with Client(parent) as client:
|
||||
result = await client.call_tool("outer", {})
|
||||
assert "task:" in str(result)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ from fastmcp import FastMCP
|
|||
from fastmcp.client import Client
|
||||
from fastmcp.dependencies import CurrentContext, Depends
|
||||
from fastmcp.prompts import PromptResult
|
||||
from fastmcp.resources import ResourceResult
|
||||
from fastmcp.server.context import Context
|
||||
|
||||
HUZZAH = "huzzah!"
|
||||
|
|
@ -266,7 +265,6 @@ async def test_resource_with_dependency(mcp: FastMCP):
|
|||
return f"Settings loaded from {storage}"
|
||||
|
||||
result = await mcp.read_resource("config://settings")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert len(result.contents) == 1
|
||||
assert result.contents[0].content == "Settings loaded from /data/config"
|
||||
|
||||
|
|
@ -343,7 +341,6 @@ async def test_resource_template_with_dependency(mcp: FastMCP):
|
|||
return f"Reading {base_path}/{filename}"
|
||||
|
||||
result = await mcp.read_resource("data://config.txt")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert len(result.contents) == 1
|
||||
assert result.contents[0].content == "Reading /var/data/config.txt"
|
||||
|
||||
|
|
@ -399,7 +396,6 @@ async def test_async_resource_context_manager_stays_open(mcp: FastMCP):
|
|||
return f"open={connection.is_open}"
|
||||
|
||||
result = await mcp.read_resource("data://config")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "open=True"
|
||||
|
||||
|
||||
|
|
@ -415,7 +411,6 @@ async def test_async_resource_template_context_manager_stays_open(mcp: FastMCP):
|
|||
return f"open={connection.is_open},user={user_id}"
|
||||
|
||||
result = await mcp.read_resource("user://123")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert isinstance(result.contents[0].content, str)
|
||||
assert "open=True" in result.contents[0].content
|
||||
|
||||
|
|
@ -525,7 +520,6 @@ async def test_sync_resource_context_manager_stays_open(mcp: FastMCP):
|
|||
return f"open={connection.is_open}"
|
||||
|
||||
result = await mcp.read_resource("data://sync")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "open=True"
|
||||
assert not conn.is_open
|
||||
|
||||
|
|
@ -551,7 +545,6 @@ async def test_sync_resource_template_context_manager_stays_open(mcp: FastMCP):
|
|||
return f"open={connection.is_open},item={item_id}"
|
||||
|
||||
result = await mcp.read_resource("item://456")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert isinstance(result.contents[0].content, str)
|
||||
assert "open=True" in result.contents[0].content
|
||||
assert not conn.is_open
|
||||
|
|
@ -663,7 +656,6 @@ async def test_resource_dependency_cannot_be_overridden_externally(mcp: FastMCP)
|
|||
|
||||
# Normal call
|
||||
result = await mcp.read_resource("data://config")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert isinstance(result.contents[0].content, str)
|
||||
assert "API Key: real_api_key" in result.contents[0].content
|
||||
|
||||
|
|
@ -689,7 +681,6 @@ async def test_resource_template_dependency_cannot_be_overridden_externally(
|
|||
|
||||
# Normal call
|
||||
result = await mcp.read_resource("user://123")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert isinstance(result.contents[0].content, str)
|
||||
assert "User: 123, Token: real_token" in result.contents[0].content
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ from fastmcp.client import Client
|
|||
from fastmcp.client.transports import FastMCPTransport, SSETransport
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
from fastmcp.prompts import PromptResult
|
||||
from fastmcp.resources import ResourceResult
|
||||
from fastmcp.server.providers import FastMCPProvider, TransformingProvider
|
||||
from fastmcp.server.providers.proxy import FastMCPProxy
|
||||
from fastmcp.tools.tool import Tool
|
||||
|
|
@ -143,7 +142,6 @@ class TestBasicMount:
|
|||
|
||||
# Test actual functionality
|
||||
resource_result = await main_app.read_resource("data://config")
|
||||
assert isinstance(resource_result, ResourceResult)
|
||||
assert resource_result.contents[0].content == "Sub resource data"
|
||||
|
||||
async def test_mount_resource_templates_no_prefix(self):
|
||||
|
|
@ -164,7 +162,6 @@ class TestBasicMount:
|
|||
|
||||
# Test actual functionality
|
||||
template_result = await main_app.read_resource("users://123/info")
|
||||
assert isinstance(template_result, ResourceResult)
|
||||
assert template_result.contents[0].content == "Sub template for user 123"
|
||||
|
||||
async def test_mount_prompts_no_prefix(self):
|
||||
|
|
@ -409,7 +406,6 @@ class TestPrefixConflictResolution:
|
|||
|
||||
# Test that reading the resource uses the first server's implementation
|
||||
result = await main_app.read_resource("shared://data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "First app data"
|
||||
|
||||
async def test_first_server_wins_resources_same_prefix(self):
|
||||
|
|
@ -438,7 +434,6 @@ class TestPrefixConflictResolution:
|
|||
|
||||
# Test that reading the resource uses the first server's implementation
|
||||
result = await main_app.read_resource("shared://api/data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "First app data"
|
||||
|
||||
async def test_first_server_wins_resource_templates_no_prefix(self):
|
||||
|
|
@ -469,7 +464,6 @@ class TestPrefixConflictResolution:
|
|||
|
||||
# Test that reading the resource uses the first server's implementation
|
||||
result = await main_app.read_resource("users://123/profile")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "First app user 123"
|
||||
|
||||
async def test_first_server_wins_resource_templates_same_prefix(self):
|
||||
|
|
@ -500,7 +494,6 @@ class TestPrefixConflictResolution:
|
|||
|
||||
# Test that reading the resource uses the first server's implementation
|
||||
result = await main_app.read_resource("users://api/123/profile")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "First app user 123"
|
||||
|
||||
async def test_first_server_wins_prompts_no_prefix(self):
|
||||
|
|
@ -639,7 +632,6 @@ class TestResourcesAndTemplates:
|
|||
|
||||
# Check that resource can be accessed
|
||||
result = await main_app.read_resource("data://data/users")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert len(result.contents) == 1
|
||||
# Note: The function returns "user1, user2" which is not valid JSON
|
||||
# This test should be updated to return proper JSON or check the string directly
|
||||
|
|
@ -663,7 +655,6 @@ class TestResourcesAndTemplates:
|
|||
|
||||
# Check template instantiation
|
||||
result = await main_app.read_resource("users://api/123/profile")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert len(result.contents) == 1
|
||||
profile = json.loads(result.contents[0].content)
|
||||
assert profile["id"] == "123"
|
||||
|
|
@ -688,7 +679,6 @@ class TestResourcesAndTemplates:
|
|||
|
||||
# Check access to the resource
|
||||
result = await main_app.read_resource("data://data/config")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert len(result.contents) == 1
|
||||
config = json.loads(result.contents[0].content)
|
||||
assert config["version"] == "1.0"
|
||||
|
|
@ -813,7 +803,6 @@ class TestProxyServer:
|
|||
|
||||
# Resource should be accessible through main app
|
||||
result = await main_app.read_resource("config://proxy/settings")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert len(result.contents) == 1
|
||||
config = json.loads(result.contents[0].content)
|
||||
assert config["api_key"] == "12345"
|
||||
|
|
@ -1264,12 +1253,10 @@ class TestDeeplyNestedMount:
|
|||
|
||||
# Resource at level 2 should work
|
||||
result = await root.read_resource("middle://middle/data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "middle data"
|
||||
|
||||
# Resource at level 3 should also work
|
||||
result = await root.read_resource("leaf://middle/leaf/data")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "leaf data"
|
||||
|
||||
async def test_three_level_nested_resource_template_invocation(self):
|
||||
|
|
@ -1291,12 +1278,10 @@ class TestDeeplyNestedMount:
|
|||
|
||||
# Resource template at level 2 should work
|
||||
result = await root.read_resource("middle://middle/item/42")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "middle item 42"
|
||||
|
||||
# Resource template at level 3 should also work
|
||||
result = await root.read_resource("leaf://middle/leaf/item/99")
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert result.contents[0].content == "leaf item 99"
|
||||
|
||||
async def test_three_level_nested_prompt_invocation(self):
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from mcp.types import AnyUrl, TextContent
|
|||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.prompts.prompt import FunctionPrompt, Prompt, PromptResult
|
||||
from fastmcp.resources import ResourceResult
|
||||
from fastmcp.resources.resource import FunctionResource, Resource
|
||||
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
|
||||
from fastmcp.server.providers import Provider
|
||||
|
|
@ -389,7 +388,6 @@ class TestProviderExecutionMethods:
|
|||
|
||||
result = await mcp.read_resource("test://data")
|
||||
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert len(result.contents) == 1
|
||||
assert result.contents[0].content == "hello world"
|
||||
|
||||
|
|
@ -412,7 +410,6 @@ class TestProviderExecutionMethods:
|
|||
|
||||
result = await mcp.read_resource("data://files/test.txt")
|
||||
|
||||
assert isinstance(result, ResourceResult)
|
||||
assert len(result.contents) == 1
|
||||
assert result.contents[0].content == "content of test.txt"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue