From 2511cff249b8853f95c37d169db4cbb7fb67a270 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 26 Dec 2025 11:43:49 -0500 Subject: [PATCH 1/2] Add task_meta parameter to read_resource() for explicit task control Adds the same task_meta parameter pattern to resources that was added to tools, enabling explicit control over sync vs task execution with type-safe return types via overloads. --- src/fastmcp/resources/resource.py | 31 +- src/fastmcp/resources/template.py | 72 ++++- .../server/providers/fastmcp_provider.py | 81 ++--- src/fastmcp/server/server.py | 79 +++-- tests/resources/test_function_resources.py | 5 +- tests/resources/test_resource_template.py | 3 +- tests/resources/test_resources.py | 1 - tests/server/middleware/test_middleware.py | 4 - .../test_local_provider_resources.py | 30 -- .../test_resource_task_meta_parameter.py | 291 ++++++++++++++++++ tests/server/test_dependencies.py | 9 - tests/server/test_mount.py | 15 - tests/server/test_providers.py | 3 - 13 files changed, 464 insertions(+), 160 deletions(-) create mode 100644 tests/server/tasks/test_resource_task_meta_parameter.py diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 1a90bdb70e..eb3e6c20cc 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -5,7 +5,7 @@ import base64 import inspect from collections.abc import Callable -from typing import TYPE_CHECKING, Annotated, Any, ClassVar +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, overload import mcp.types @@ -27,7 +27,7 @@ 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 +300,42 @@ def convert_result(self, raw_value: Any) -> ResourceResult: # 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 + if task_meta is not None and task_meta.fn_key is None: + task_meta = TaskMeta(ttl=task_meta.ttl, 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 diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 3f73d512e3..e8628937c4 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -5,7 +5,7 @@ import inspect import re from collections.abc import Callable -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, overload from urllib.parse import parse_qs, unquote import mcp.types @@ -23,7 +23,7 @@ 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 +177,18 @@ def convert_result(self, raw_value: Any) -> ResourceResult: # 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 +196,29 @@ async def _read( 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 (template pattern) if not already set + if task_meta is not None and task_meta.fn_key is None: + task_meta = TaskMeta(ttl=task_meta.ttl, 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 +314,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 (template pattern) if not already set + if task_meta is not None and task_meta.fn_key is None: + task_meta = TaskMeta(ttl=task_meta.ttl, 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 diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index ee345bd9e5..be7ae7c8c3 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -13,7 +13,7 @@ import re from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, overload import mcp.types from mcp.types import AnyUrl @@ -148,19 +148,29 @@ def wrap(cls, server: Any, resource: Resource) -> FastMCPProviderResource: 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 +287,36 @@ async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: mime_type=self.mime_type, ) + @overload async def _read( - self, uri: str, params: dict[str, Any] - ) -> ResourceResult | mcp.types.CreateTaskResult: - """Delegate to child server's read_resource(). + self, uri: str, params: dict[str, Any], task_meta: None = None + ) -> ResourceResult: ... - Skips task routing at this layer - the child's template._read() will - check _task_metadata contextvar and submit to Docket if appropriate. + @overload + async def _read( + self, uri: str, params: dict[str, Any], task_meta: TaskMeta + ) -> mcp.types.CreateTaskResult: ... - 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(). + 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() with task_meta. - Only sets _docket_fn_key if not already set - in nested mounts, the - outermost wrapper sets the key and inner wrappers preserve it. - """ - from fastmcp.server.dependencies import _docket_fn_key + Passes task_meta through to the child server so it can handle + backgrounding appropriately. + 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(). + """ # 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 template pattern 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(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. diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index eb161f7510..b3c6aa163f 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1221,11 +1221,30 @@ async def call_tool( 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 +1255,23 @@ async def read_resource( 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 +1282,14 @@ async def read_resource( 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 +1297,7 @@ async def read_resource( 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 +1318,9 @@ async def read_resource( 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 @@ -1566,8 +1587,9 @@ async def _read_resource_mcp( ) -> 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,26 @@ async def _read_resource_mcp( 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 + # fn_key is left as None - each component enriches it in _read() + 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: diff --git a/tests/resources/test_function_resources.py b/tests/resources/test_function_resources.py index c819b11b52..45ddf97ea3 100644 --- a/tests/resources/test_function_resources.py +++ b/tests/resources/test_function_resources.py @@ -1,7 +1,7 @@ import pytest from pydantic import AnyUrl, BaseModel -from fastmcp.resources.resource import FunctionResource, ResourceContent, ResourceResult +from fastmcp.resources.resource import FunctionResource, ResourceContent class TestFunctionResource: @@ -42,7 +42,6 @@ def get_data() -> str: # _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 @@ def get_data() -> bytes: # _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 @@ async def get_data() -> str: # _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" diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index b583276613..7d7a3bb33d 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -6,7 +6,7 @@ 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 @@ def my_func(key: str, value: int) -> str: # _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" diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py index 8b9906cc43..c872a33b29 100644 --- a/tests/resources/test_resources.py +++ b/tests/resources/test_resources.py @@ -303,7 +303,6 @@ def fn() -> str: 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" diff --git a/tests/server/middleware/test_middleware.py b/tests/server/middleware/test_middleware.py index 5ebae8451e..cbf5e4f0de 100644 --- a/tests/server/middleware/test_middleware.py +++ b/tests/server/middleware/test_middleware.py @@ -7,7 +7,6 @@ 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 @@ def test_resource() -> str: 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 @@ def test_resource() -> str: 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 @@ def get_item(item_id: int) -> str: 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 diff --git a/tests/server/providers/test_local_provider_resources.py b/tests/server/providers/test_local_provider_resources.py index af42f8ddb7..e485586fcb 100644 --- a/tests/server/providers/test_local_provider_resources.py +++ b/tests/server/providers/test_local_provider_resources.py @@ -82,7 +82,6 @@ def get_data(name: str) -> str: 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 @@ def get_data(org: str, repo: str) -> str: 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 @@ def func(**kwargs: int) -> str: 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 @@ def add(x: int, y: int = 10) -> str: 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 @@ def get_data(name: str) -> str: 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 @@ def template_resource(param: str) -> str: 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 @@ def get_data(id: str, format: str = "json", limit: int = 10) -> str: 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 @@ def template_resource_with_params(x: str, y: str) -> str: 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 @@ def template_resource(param: str) -> str: 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 @@ def get_data() -> str: 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 @@ def get_data() -> str: 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 @@ def get_data(self) -> str: ) 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 @@ def get_data(cls) -> str: ) 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 @@ def get_data() -> str: 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 @@ async def get_data() -> str: 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 @@ def get_data() -> str: 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 @@ def get_widget() -> ResourceResult: ) result = await mcp.read_resource("resource://widget") - assert isinstance(result, ResourceResult) assert len(result.contents) == 1 assert result.contents[0].content == "content" assert result.contents[0].mime_type == "text/html" @@ -521,7 +499,6 @@ def get_binary() -> ResourceResult: ) 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 @@ def get_plain() -> ResourceResult: 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 @@ def get_data(name: str) -> str: 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 @@ def get_data(name: str) -> str: 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 @@ def get_data(self, name: str) -> str: 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 @@ def get_data(cls, name: str) -> str: 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 @@ def get_data(name: str) -> str: 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 @@ async def get_data(name: str) -> str: 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): diff --git a/tests/server/tasks/test_resource_task_meta_parameter.py b/tests/server/tasks/test_resource_task_meta_parameter.py new file mode 100644 index 0000000000..756e5e84f5 --- /dev/null +++ b/tests/server/tasks/test_resource_task_meta_parameter.py @@ -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) diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py index 37e2836304..39e2130ba3 100644 --- a/tests/server/test_dependencies.py +++ b/tests/server/test_dependencies.py @@ -9,7 +9,6 @@ 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 get_settings(storage: str = Depends(get_storage_path)) -> str: 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 get_file(filename: str, base_path: str = Depends(get_base_path)) -> st 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 load_config(connection: Connection = Depends(get_connection)) -> str: 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 get_user( 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 load_sync(connection: Connection = Depends(get_sync_connection)) -> st 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 get_item( 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 get_config(api_key: str = Depends(get_api_key)) -> str: # 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 get_user(user_id: str, token: str = Depends(get_auth_token)) -> str: # 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 diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index ad20c2bdda..7659ab9341 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -10,7 +10,6 @@ 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 @@ def sub_resource(): # 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 @@ def sub_template(user_id: str): # 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 @@ def second_resource(): # 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 @@ def second_resource(): # 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 @@ def second_template(user_id: str): # 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 @@ def second_template(user_id: str): # 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 @@ async def get_users() -> str: # 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 @@ def get_user_profile(user_id: str) -> str: # 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 @@ def get_config() -> str: # 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 @@ def get_config() -> str: # 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 @@ def middle_data() -> str: # 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 @@ def middle_item(id: str) -> str: # 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): diff --git a/tests/server/test_providers.py b/tests/server/test_providers.py index 24e4679f85..28311c003d 100644 --- a/tests/server/test_providers.py +++ b/tests/server/test_providers.py @@ -8,7 +8,6 @@ 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 @@ async def list_resources(self) -> Sequence[Resource]: 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 @@ async def list_resource_templates(self) -> Sequence[ResourceTemplate]: 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" From 13f7000570cd7389add376e667e1a9699e1675d6 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:51:08 -0500 Subject: [PATCH 2/2] Unify task_meta handling for tools and resources - MCP routes no longer set fn_key - components enrich it with self.key - Provider wrappers set fn_key to parent's namespaced key before delegating - Use dataclasses.replace() instead of creating new TaskMeta instances - Add tests for mounted components with task_meta parameter --- src/fastmcp/resources/resource.py | 5 +- src/fastmcp/resources/template.py | 9 +- .../server/providers/fastmcp_provider.py | 27 +++- src/fastmcp/server/server.py | 25 +-- src/fastmcp/tools/tool.py | 30 +++- tests/server/tasks/test_task_mount.py | 144 ++++++++++++++++++ 6 files changed, 219 insertions(+), 21 deletions(-) diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index eb3e6c20cc..61739bb5e7 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -5,6 +5,7 @@ import base64 import inspect from collections.abc import Callable +from dataclasses import replace from typing import TYPE_CHECKING, Annotated, Any, ClassVar, overload import mcp.types @@ -330,9 +331,9 @@ async def _read( """ from fastmcp.server.tasks.routing import check_background_task - # Enrich task_meta with fn_key if not already set + # 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 = TaskMeta(ttl=task_meta.ttl, fn_key=self.key) + task_meta = replace(task_meta, fn_key=self.key) task_result = await check_background_task( component=self, task_type="resource", arguments=None, task_meta=task_meta diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index e8628937c4..e56746ca0c 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -5,6 +5,7 @@ import inspect import re from collections.abc import Callable +from dataclasses import replace from typing import TYPE_CHECKING, Any, ClassVar, overload from urllib.parse import parse_qs, unquote @@ -213,9 +214,9 @@ async def _read( """ from fastmcp.server.tasks.routing import check_background_task - # Enrich task_meta with fn_key (template pattern) if not already set + # 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 = TaskMeta(ttl=task_meta.ttl, fn_key=self.key) + task_meta = replace(task_meta, fn_key=self.key) task_result = await check_background_task( component=self, task_type="template", arguments=params, task_meta=task_meta @@ -345,9 +346,9 @@ async def _read( """ from fastmcp.server.tasks.routing import check_background_task - # Enrich task_meta with fn_key (template pattern) if not already set + # 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 = TaskMeta(ttl=task_meta.ttl, fn_key=self.key) + task_meta = replace(task_meta, fn_key=self.key) task_result = await check_background_task( component=self, task_type="template", arguments=params, task_meta=task_meta diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index be7ae7c8c3..e94ac782ad 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -13,6 +13,7 @@ import re from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager +from dataclasses import replace from typing import TYPE_CHECKING, Any, overload import mcp.types @@ -85,6 +86,20 @@ def wrap(cls, server: Any, tool: Tool) -> FastMCPProviderTool: 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 @@ async def _run( 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 ) @@ -312,9 +335,9 @@ async def _read( # Expand the original template with params to get internal URI original_uri = _expand_uri_template(self._original_uri_template or "", params) - # Enrich fn_key with parent's template pattern before delegating to child + # 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 = TaskMeta(ttl=task_meta.ttl, fn_key=self.key) + task_meta = replace(task_meta, fn_key=self.key) return await self._server.read_resource(original_uri, task_meta=task_meta) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index b3c6aa163f..e4b3fde2dc 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1176,9 +1176,8 @@ async def call_tool( 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: @@ -1557,17 +1556,18 @@ async def _call_tool_mcp( ) 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 @@ -1600,8 +1600,13 @@ async def _read_resource_mcp( logger.debug(f"[{self.name}] Handler called: read_resource %s", uri) try: - # Extract SEP-1686 task metadata from request context - # fn_key is left as None - each component enriches it in _read() + # 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 diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 4c0d3aa3aa..bba0302ee5 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -3,7 +3,7 @@ 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 @@ Generic, TypeAlias, get_type_hints, + overload, ) import mcp.types @@ -277,6 +278,20 @@ def convert_result(self, raw_value: Any) -> ToolResult: 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 @@ async def _run( 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 @@ async def _run( """ 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", diff --git a/tests/server/tasks/test_task_mount.py b/tests/server/tasks/test_task_mount.py index 395e5dc13a..f3ebba85af 100644 --- a/tests/server/tasks/test_task_mount.py +++ b/tests/server/tasks/test_task_mount.py @@ -773,3 +773,147 @@ async def get_item(id: str) -> str: "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)