diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 8a9150757c..98597233cf 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -5,7 +5,7 @@ import inspect import json from collections.abc import Callable -from typing import TYPE_CHECKING, Any, ClassVar, Literal +from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload import pydantic import pydantic_core @@ -27,7 +27,7 @@ from fastmcp.exceptions import PromptError 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.logging import get_logger @@ -302,9 +302,24 @@ def convert_result(self, raw_value: Any) -> PromptResult: f"got {type(raw_value).__name__}" ) + @overload async def _render( self, arguments: dict[str, Any] | None = None, + task_meta: None = None, + ) -> PromptResult: ... + + @overload + async def _render( + self, + arguments: dict[str, Any] | None, + task_meta: TaskMeta, + ) -> mcp.types.CreateTaskResult: ... + + async def _render( + self, + arguments: dict[str, Any] | None = None, + task_meta: TaskMeta | None = None, ) -> PromptResult | mcp.types.CreateTaskResult: """Server entry point that handles task routing. @@ -312,16 +327,27 @@ async def _render( task_config.mode to "supported" or "required". The server calls this method instead of render() directly. + Args: + arguments: Prompt arguments + task_meta: If provided, execute as background task and return + CreateTaskResult. If None (default), execute synchronously and + return PromptResult. + + Returns: + PromptResult when task_meta is None. + CreateTaskResult when task_meta is provided. + Subclasses can override this to customize task routing behavior. For example, FastMCPProviderPrompt 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 task_result = await check_background_task( - component=self, task_type="prompt", key=key, arguments=arguments + component=self, + task_type="prompt", + arguments=arguments, + task_meta=task_meta, ) if task_result: return task_result diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 61739bb5e7..3c44382c45 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -5,7 +5,6 @@ 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 @@ -331,10 +330,6 @@ async def _read( """ 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="resource", arguments=None, task_meta=task_meta ) diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index e56746ca0c..ef124d18a4 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -5,7 +5,6 @@ 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 @@ -214,10 +213,6 @@ async def _read( """ 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="template", arguments=params, task_meta=task_meta ) @@ -346,10 +341,6 @@ async def _read( """ 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="template", arguments=params, task_meta=task_meta ) diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index b942c19bdb..ec74cff83d 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -40,16 +40,6 @@ "server", default=None ) -# ContextVar for propagating task metadata through the async call stack -# When set, indicates this call should be executed as a background task -_task_metadata: ContextVar[dict[str, Any] | None] = ContextVar( - "task_metadata", default=None -) - -# ContextVar for the component's Docket function lookup key (with namespace prefix) -# Used by Tool._run(), Resource._read(), Prompt._render() to find the registered function -_docket_fn_key: ContextVar[str | None] = ContextVar("docket_fn_key", default=None) - __all__ = [ "AccessToken", "CurrentContext", @@ -62,7 +52,6 @@ "get_http_headers", "get_http_request", "get_server", - "get_task_metadata", "resolve_dependencies", "without_injected_parameters", ] @@ -277,16 +266,6 @@ def get_context() -> Context: return context -def get_task_metadata() -> dict[str, Any] | None: - """Get the current task metadata from the context. - - Returns: - The task metadata dict if this is a background task request, - or None if this is a normal execution. - """ - return _task_metadata.get() - - class _CurrentContext(Dependency): """Internal dependency class for CurrentContext.""" diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index e94ac782ad..3fb44ebc17 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -13,7 +13,6 @@ 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 @@ -108,16 +107,9 @@ async def _run( """Delegate to child server's call_tool() 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 tool name) so that - when the child tool delegates to Docket, it uses the correct lookup key - that was registered via get_tasks(). + backgrounding appropriately. fn_key is already set by the parent + server before calling this method. """ - # 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 ) @@ -183,16 +175,9 @@ async def _read( """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(). + backgrounding appropriately. fn_key is already set by the parent + server before calling this method. """ - # 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) @@ -229,28 +214,47 @@ def wrap(cls, server: Any, prompt: Prompt) -> FastMCPProviderPrompt: task_config=prompt.task_config, ) + @overload async def _render( - self, arguments: dict[str, Any] | None = None + self, + arguments: dict[str, Any] | None = None, + task_meta: None = None, + ) -> PromptResult: ... + + @overload + async def _render( + self, + arguments: dict[str, Any] | None, + task_meta: TaskMeta, + ) -> mcp.types.CreateTaskResult: ... + + async def _render( + self, + arguments: dict[str, Any] | None = None, + task_meta: TaskMeta | None = None, ) -> PromptResult | mcp.types.CreateTaskResult: - """Skip task routing - delegate to render() which calls child middleware. + """Delegate to child server's render_prompt() with task_meta. - The actual underlying prompt will check _task_metadata contextvar and - submit to Docket if appropriate. This wrapper just passes through. + Passes task_meta through to the child server so it can handle + backgrounding appropriately. fn_key is already set by the parent + server before calling this method. """ - return await self.render(arguments) + return await self._server.render_prompt( + self._original_name, arguments, task_meta=task_meta + ) async def render( self, arguments: dict[str, Any] | None = None ) -> PromptResult | mcp.types.CreateTaskResult: # type: ignore[override] - """Delegate to child server's render_prompt(). + """Not implemented - use _render() which delegates to child server. - Note: The _docket_fn_key contextvar is intentionally NOT updated here. - The parent set it to the full namespaced name (e.g., c_gc_greet) which - is what the function is registered under in Docket. All provider layers - pass this through unchanged so the eventual prompt._render() uses the - correct Docket lookup key. + FastMCPProviderPrompt._render() handles all execution by delegating + to the child server's render_prompt() with task_meta. """ - return await self._server.render_prompt(self._original_name, arguments) + raise NotImplementedError( + "FastMCPProviderPrompt.render() should not be called directly. " + "Use _render() which delegates to the child server's render_prompt()." + ) class FastMCPProviderResourceTemplate(ResourceTemplate): @@ -326,19 +330,12 @@ async def _read( """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 template pattern) - so that when the child template delegates to Docket, it uses the correct - lookup key that was registered via get_tasks(). + backgrounding appropriately. fn_key is already set by the parent + server before calling this method. """ # 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 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: diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index e4b3fde2dc..ccabd40788 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -21,6 +21,7 @@ asynccontextmanager, suppress, ) +from dataclasses import replace from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Any, Generic, Literal, cast, overload @@ -1176,8 +1177,9 @@ async def call_tool( ToolError: If tool execution fails ValidationError: If arguments fail validation """ - # Note: fn_key enrichment happens in Tool._run(), not here, - # so that provider wrappers can enrich with their namespaced key. + # Note: fn_key enrichment happens here after finding the tool. + # For mounted servers, the parent's provider sets fn_key to the + # namespaced key before delegating, ensuring correct Docket routing. async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: @@ -1204,6 +1206,9 @@ async def call_tool( for provider in self._providers: tool = await provider.get_tool(name) if tool is not None and self._is_component_enabled(tool): + # Set fn_key for background task routing + if task_meta is not None and task_meta.fn_key is None: + task_meta = replace(task_meta, fn_key=tool.key) try: return await tool._run(arguments or {}, task_meta=task_meta) except FastMCPError: @@ -1266,10 +1271,12 @@ async def read_resource( 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 + # Note: fn_key enrichment happens here after finding the resource/template. + # Resources and templates use different key formats: + # - Resources use resource.key (derived from the concrete URI) + # - Templates use template.key (the template pattern) + # For mounted servers, the parent's provider sets fn_key to the + # namespaced key before delegating, ensuring correct Docket routing. async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: @@ -1295,6 +1302,9 @@ async def read_resource( for provider in self._providers: resource = await provider.get_resource(uri) if resource is not None and self._is_component_enabled(resource): + # Set fn_key for background task routing + if task_meta is not None and task_meta.fn_key is None: + task_meta = replace(task_meta, fn_key=resource.key) try: return await resource._read(task_meta=task_meta) except (FastMCPError, McpError): @@ -1316,6 +1326,9 @@ async def read_resource( if template is not None and self._is_component_enabled(template): params = template.matches(uri) if params is not None: + # Set fn_key for background task routing + if task_meta is not None and task_meta.fn_key is None: + task_meta = replace(task_meta, fn_key=template.key) try: return await template._read( uri, params, task_meta=task_meta @@ -1335,12 +1348,33 @@ async def read_resource( raise NotFoundError(f"Unknown resource: {uri!r}") + @overload + async def render_prompt( + self, + name: str, + arguments: dict[str, Any] | None = None, + *, + run_middleware: bool = True, + task_meta: None = None, + ) -> PromptResult: ... + + @overload + async def render_prompt( + self, + name: str, + arguments: dict[str, Any] | None = None, + *, + run_middleware: bool = True, + task_meta: TaskMeta, + ) -> mcp.types.CreateTaskResult: ... + async def render_prompt( self, name: str, arguments: dict[str, Any] | None = None, *, run_middleware: bool = True, + task_meta: TaskMeta | None = None, ) -> PromptResult | mcp.types.CreateTaskResult: """Render a prompt by name. @@ -1352,10 +1386,13 @@ async def render_prompt( arguments: Prompt arguments (optional) 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 PromptResult. Returns: - PromptResult with messages and optional description. - May return CreateTaskResult if called in MCP context with task metadata. + PromptResult when task_meta is None. + CreateTaskResult when task_meta is provided. Raises: NotFoundError: If prompt not found or disabled @@ -1378,6 +1415,7 @@ async def render_prompt( context.message.name, context.message.arguments, run_middleware=False, + task_meta=task_meta, ), ) @@ -1385,8 +1423,11 @@ async def render_prompt( for provider in self._providers: prompt = await provider.get_prompt(name) if prompt is not None and self._is_component_enabled(prompt): + # Set fn_key for background task routing + if task_meta is not None and task_meta.fn_key is None: + task_meta = replace(task_meta, fn_key=prompt.key) try: - return await prompt._render(arguments) + return await prompt._render(arguments, task_meta=task_meta) except (FastMCPError, McpError): logger.exception(f"Error rendering prompt {name!r}") raise @@ -1557,10 +1598,7 @@ async def _call_tool_mcp( try: # 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. + # fn_key is set by call_tool() after finding the tool. task_meta: TaskMeta | None = None try: ctx = self._mcp_server.request_context @@ -1601,12 +1639,7 @@ async def _read_resource_mcp( try: # 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. + # fn_key is set by read_resource() after finding the resource/template. task_meta: TaskMeta | None = None try: ctx = self._mcp_server.request_context @@ -1632,8 +1665,9 @@ async def _get_prompt_mcp( ) -> mcp.types.GetPromptResult | mcp.types.CreateTaskResult: """Handle MCP 'getPrompt' requests. - Sets task metadata contextvar and calls render_prompt(). The prompt's - _render() method handles the backgrounding decision. + Extracts task metadata from MCP request context and passes it explicitly + to render_prompt(). The prompt's _render() method handles the backgrounding + decision, ensuring middleware runs before Docket. Args: name: The prompt name @@ -1642,35 +1676,28 @@ async def _get_prompt_mcp( Returns: GetPromptResult or CreateTaskResult for background execution """ - from fastmcp.server.dependencies import _docket_fn_key, _task_metadata - logger.debug( f"[{self.name}] Handler called: get_prompt %s with %s", name, arguments ) 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. + # fn_key is set by render_prompt() after finding the prompt. + 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 prompt._render() can access them - task_token = _task_metadata.set(task_meta_dict) - key_token = _docket_fn_key.set(Prompt.make_key(name)) - try: - result = await self.render_prompt(name, arguments) - - if isinstance(result, mcp.types.CreateTaskResult): - return result - return result.to_mcp_prompt_result() - finally: - _task_metadata.reset(task_token) - _docket_fn_key.reset(key_token) + result = await self.render_prompt(name, arguments, task_meta=task_meta) + + if isinstance(result, mcp.types.CreateTaskResult): + return result + return result.to_mcp_prompt_result() except DisabledError as e: raise NotFoundError(f"Unknown prompt: {name!r}") from e except NotFoundError: diff --git a/src/fastmcp/server/tasks/routing.py b/src/fastmcp/server/tasks/routing.py index 0f8fb5bc2a..ab9e240c8e 100644 --- a/src/fastmcp/server/tasks/routing.py +++ b/src/fastmcp/server/tasks/routing.py @@ -11,7 +11,6 @@ from mcp.shared.exceptions import McpError from mcp.types import METHOD_NOT_FOUND, ErrorData -from fastmcp.server.dependencies import get_task_metadata from fastmcp.server.tasks.config import TaskMeta from fastmcp.server.tasks.handlers import submit_to_docket @@ -27,9 +26,6 @@ async def check_background_task( component: Tool | Resource | ResourceTemplate | Prompt, task_type: TaskType, - # TODO: Remove `key` parameter when resources and prompts are updated to use - # explicit task_meta parameter like tools do - key: str | None = None, arguments: dict[str, Any] | None = None, task_meta: TaskMeta | None = None, ) -> mcp.types.CreateTaskResult | None: @@ -38,10 +34,8 @@ async def check_background_task( Args: component: The MCP component task_type: Type of task ("tool", "resource", "template", "prompt") - key: Docket registration key (deprecated, use task_meta.fn_key instead) arguments: Arguments for tool/prompt/template execution task_meta: Task execution metadata. If provided, execute as background task. - When None, falls back to reading from contextvar for backwards compat. Returns: CreateTaskResult if submitted to docket, None for sync execution @@ -50,16 +44,6 @@ async def check_background_task( McpError: If mode="required" but no task metadata, or mode="forbidden" but task metadata is present """ - # For backwards compatibility: if task_meta not provided, check contextvar - # This is used by resources/prompts which haven't been updated yet - if task_meta is None: - task_meta_dict = get_task_metadata() - if task_meta_dict is not None: - task_meta = TaskMeta( - ttl=task_meta_dict.get("ttl"), - fn_key=key, # Use key parameter for backwards compat - ) - task_config = component.task_config # Infer label from component @@ -87,7 +71,6 @@ async def check_background_task( if not task_meta: return None - # fn_key should be set by caller (FastMCP.call_tool enriches it) - # Fall back to key parameter for backwards compat, then component.key - fn_key = task_meta.fn_key or key or component.key + # fn_key is expected to be set; fall back to component.key for direct calls + fn_key = task_meta.fn_key or component.key return await submit_to_docket(task_type, fn_key, component, arguments, task_meta) diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index bba0302ee5..d868ab5661 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, replace +from dataclasses import dataclass from typing import ( TYPE_CHECKING, Annotated, @@ -319,10 +319,6 @@ 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/providers/test_local_provider_prompts.py b/tests/server/providers/test_local_provider_prompts.py index 2afbd7d013..5b8aa59951 100644 --- a/tests/server/providers/test_local_provider_prompts.py +++ b/tests/server/providers/test_local_provider_prompts.py @@ -10,7 +10,7 @@ from fastmcp import Client, Context, FastMCP from fastmcp.exceptions import NotFoundError -from fastmcp.prompts.prompt import FunctionPrompt, Prompt, PromptResult +from fastmcp.prompts.prompt import FunctionPrompt, Prompt class TestPromptContext: @@ -73,7 +73,6 @@ def fn() -> str: assert any(p.name == "fn" for p in prompts) result = await mcp.render_prompt("fn") - assert isinstance(result, PromptResult) assert len(result.messages) == 1 assert isinstance(result.messages[0].content, TextContent) assert result.messages[0].content.text == "Hello, world!" @@ -126,7 +125,6 @@ def test_prompt(name: str, greeting: str = "Hello") -> str: assert prompt.arguments[1].required is False result = await mcp.render_prompt("test_prompt", {"name": "World"}) - assert isinstance(result, PromptResult) assert len(result.messages) == 1 message = result.messages[0] assert isinstance(message.content, TextContent) @@ -135,7 +133,6 @@ def test_prompt(name: str, greeting: str = "Hello") -> str: result = await mcp.render_prompt( "test_prompt", {"name": "World", "greeting": "Hi"} ) - assert isinstance(result, PromptResult) assert len(result.messages) == 1 message = result.messages[0] assert isinstance(message.content, TextContent) @@ -155,7 +152,6 @@ def test_prompt(self) -> str: mcp.add_prompt(Prompt.from_function(obj.test_prompt, name="test_prompt")) result = await mcp.render_prompt("test_prompt") - assert isinstance(result, PromptResult) assert len(result.messages) == 1 message = result.messages[0] assert isinstance(message.content, TextContent) @@ -174,7 +170,6 @@ def test_prompt(cls) -> str: mcp.add_prompt(Prompt.from_function(MyClass.test_prompt, name="test_prompt")) result = await mcp.render_prompt("test_prompt") - assert isinstance(result, PromptResult) assert len(result.messages) == 1 message = result.messages[0] assert isinstance(message.content, TextContent) @@ -201,7 +196,6 @@ def test_prompt() -> str: return "Static Hello, world!" result = await mcp.render_prompt("test_prompt") - assert isinstance(result, PromptResult) assert len(result.messages) == 1 message = result.messages[0] assert isinstance(message.content, TextContent) @@ -215,7 +209,6 @@ async def test_prompt() -> str: return "Async Hello, world!" result = await mcp.render_prompt("test_prompt") - assert isinstance(result, PromptResult) assert len(result.messages) == 1 message = result.messages[0] assert isinstance(message.content, TextContent) @@ -248,7 +241,6 @@ def my_function() -> str: assert not any(p.name == "my_function" for p in prompts) result = await mcp.render_prompt("string_named_prompt") - assert isinstance(result, PromptResult) assert len(result.messages) == 1 assert isinstance(result.messages[0].content, TextContent) assert result.messages[0].content.text == "Hello from string named prompt!" @@ -270,7 +262,6 @@ def standalone_function() -> str: assert prompt is result_fn result = await mcp.render_prompt("direct_call_prompt") - assert isinstance(result, PromptResult) assert len(result.messages) == 1 assert isinstance(result.messages[0].content, TextContent) assert result.messages[0].content.text == "Hello from direct call!" @@ -299,7 +290,6 @@ def test_prompt() -> str: return "Static Hello, world!" result = await mcp.render_prompt("test_prompt") - assert isinstance(result, PromptResult) assert len(result.messages) == 1 message = result.messages[0] assert isinstance(message.content, TextContent) diff --git a/tests/server/tasks/test_resource_task_meta_parameter.py b/tests/server/tasks/test_resource_task_meta_parameter.py index 756e5e84f5..50650f9d56 100644 --- a/tests/server/tasks/test_resource_task_meta_parameter.py +++ b/tests/server/tasks/test_resource_task_meta_parameter.py @@ -10,7 +10,7 @@ from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.resources.resource import Resource, ResourceResult +from fastmcp.resources.resource import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.tasks.config import TaskMeta @@ -28,7 +28,6 @@ async def simple_resource() -> str: 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): @@ -42,7 +41,6 @@ async def task_enabled_resource() -> str: # 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): @@ -86,7 +84,6 @@ async def get_item(id: str) -> str: 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): @@ -100,7 +97,6 @@ async def get_item(id: str) -> str: # 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): diff --git a/tests/server/tasks/test_task_mount.py b/tests/server/tasks/test_task_mount.py index f3ebba85af..b601792e24 100644 --- a/tests/server/tasks/test_task_mount.py +++ b/tests/server/tasks/test_task_mount.py @@ -805,7 +805,6 @@ async def outer() -> str: 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: @@ -830,7 +829,6 @@ 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: @@ -855,7 +853,6 @@ 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: @@ -883,7 +880,6 @@ 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: @@ -911,7 +907,57 @@ 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) + + async def test_mounted_prompt_with_task_meta_creates_task(self): + """Mounted prompt called with task_meta returns CreateTaskResult.""" + from fastmcp.server.tasks.config import TaskMeta + + child = FastMCP("Child") + + @child.prompt(task=True) + async def greet(name: str) -> str: + return f"Hello, {name}!" + + parent = FastMCP("Parent") + parent.mount(child, namespace="child") + + @parent.tool + async def outer() -> str: + result = await parent.render_prompt( + "child_greet", {"name": "World"}, task_meta=TaskMeta(ttl=300) + ) + 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_prompt_with_task_meta(self): + """Three-level nested prompt works with task_meta.""" + from fastmcp.server.tasks.config import TaskMeta + + grandchild = FastMCP("Grandchild") + + @grandchild.prompt(task=True) + async def describe(topic: str) -> str: + return f"Information about {topic}" + + 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.render_prompt( + "c_gc_describe", {"topic": "FastMCP"}, task_meta=TaskMeta(ttl=300) + ) return f"task:{result.task.taskId}" async with Client(parent) as client: diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py index 39e2130ba3..7be5f479fc 100644 --- a/tests/server/test_dependencies.py +++ b/tests/server/test_dependencies.py @@ -8,7 +8,6 @@ from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.dependencies import CurrentContext, Depends -from fastmcp.prompts import PromptResult from fastmcp.server.context import Context HUZZAH = "huzzah!" @@ -299,7 +298,6 @@ async def custom_prompt(topic: str, tone: str = Depends(get_tone)) -> str: return f"Write about {topic} in a {tone} tone" result = await mcp.render_prompt("custom_prompt", {"topic": "Python"}) - assert isinstance(result, PromptResult) assert len(result.messages) == 1 message = result.messages[0] content = message.content @@ -427,7 +425,6 @@ async def research_prompt( return f"open={connection.is_open},topic={topic}" result = await mcp.render_prompt("research_prompt", {"topic": "AI"}) - assert isinstance(result, PromptResult) message = result.messages[0] content = message.content assert isinstance(content, TextContent) @@ -571,7 +568,6 @@ async def sync_prompt( return f"open={connection.is_open},topic={topic}" result = await mcp.render_prompt("sync_prompt", {"topic": "test"}) - assert isinstance(result, PromptResult) message = result.messages[0] content = message.content assert isinstance(content, TextContent) @@ -624,7 +620,6 @@ async def secure_prompt(topic: str, secret: str = Depends(get_secret)) -> str: # Normal call - should use dependency result = await mcp.render_prompt("secure_prompt", {"topic": "test"}) - assert isinstance(result, PromptResult) message = result.messages[0] content = message.content assert isinstance(content, TextContent) @@ -635,7 +630,6 @@ async def secure_prompt(topic: str, secret: str = Depends(get_secret)) -> str: "secure_prompt", {"topic": "test", "secret": "HACKED"}, # Attempt override ) - assert isinstance(result, PromptResult) message = result.messages[0] content = message.content assert isinstance(content, TextContent) diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 7659ab9341..47f1adcc2c 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -9,7 +9,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.server.providers import FastMCPProvider, TransformingProvider from fastmcp.server.providers.proxy import FastMCPProxy from fastmcp.tools.tool import Tool @@ -182,7 +181,6 @@ def sub_prompt() -> str: # Test actual functionality prompt_result = await main_app.render_prompt("sub_prompt") - assert isinstance(prompt_result, PromptResult) assert prompt_result.messages is not None @@ -522,7 +520,6 @@ def second_shared_prompt() -> str: # Test that getting the prompt uses the first server's implementation result = await main_app.render_prompt("shared_prompt") - assert isinstance(result, PromptResult) assert result.messages is not None assert isinstance(result.messages[0].content, TextContent) assert result.messages[0].content.text == "First app prompt" @@ -553,7 +550,6 @@ def second_shared_prompt() -> str: # Test that getting the prompt uses the first server's implementation result = await main_app.render_prompt("api_shared_prompt") - assert isinstance(result, PromptResult) assert result.messages is not None assert isinstance(result.messages[0].content, TextContent) assert result.messages[0].content.text == "First app prompt" @@ -705,7 +701,6 @@ def greeting(name: str) -> str: # Render the prompt result = await main_app.render_prompt("assistant_greeting", {"name": "World"}) - assert isinstance(result, PromptResult) assert result.messages is not None # The message should contain our greeting text @@ -728,7 +723,6 @@ def farewell(name: str) -> str: # Render the prompt result = await main_app.render_prompt("assistant_farewell", {"name": "World"}) - assert isinstance(result, PromptResult) assert result.messages is not None # The message should contain our farewell text @@ -825,7 +819,6 @@ def welcome(name: str) -> str: # Prompt should be accessible through main app result = await main_app.render_prompt("proxy_welcome", {"name": "World"}) - assert isinstance(result, PromptResult) assert result.messages is not None # The message should contain our welcome text @@ -1303,13 +1296,11 @@ def middle_prompt(name: str) -> str: # Prompt at level 2 should work result = await root.render_prompt("middle_middle_prompt", {"name": "World"}) - assert isinstance(result, PromptResult) assert isinstance(result.messages[0].content, TextContent) assert "Hello from middle: World" in result.messages[0].content.text # Prompt at level 3 should also work result = await root.render_prompt("middle_leaf_leaf_prompt", {"name": "Test"}) - assert isinstance(result, PromptResult) assert isinstance(result.messages[0].content, TextContent) assert "Hello from leaf: Test" in result.messages[0].content.text diff --git a/tests/server/test_providers.py b/tests/server/test_providers.py index 28311c003d..9f22bd0692 100644 --- a/tests/server/test_providers.py +++ b/tests/server/test_providers.py @@ -7,7 +7,7 @@ from mcp.types import AnyUrl, TextContent from fastmcp import FastMCP -from fastmcp.prompts.prompt import FunctionPrompt, Prompt, PromptResult +from fastmcp.prompts.prompt import FunctionPrompt, Prompt from fastmcp.resources.resource import FunctionResource, Resource from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate from fastmcp.server.providers import Provider @@ -432,7 +432,6 @@ async def list_prompts(self) -> Sequence[Prompt]: result = await mcp.render_prompt("greeting", {"name": "World"}) - assert isinstance(result, PromptResult) assert len(result.messages) == 1 assert isinstance(result.messages[0].content, TextContent) assert result.messages[0].content.text == "Hello, World!" diff --git a/v3-notes/prompt-internal-types.md b/v3-notes/prompt-internal-types.md new file mode 100644 index 0000000000..addbd01c37 --- /dev/null +++ b/v3-notes/prompt-internal-types.md @@ -0,0 +1,121 @@ +# Prompt Internal Types - Message and PromptResult + +**Version:** 3.0.0 +**Impact:** Breaking change for prompts returning `mcp.types.PromptMessage` + +## Summary + +Prompts now use FastMCP's `Message` and `PromptResult` types internally, following the same pattern as resources (#2734). MCP SDK types are only used at the protocol boundary. + +## What Changed + +### Before (v2.x) +```python +from mcp.types import PromptMessage, TextContent + +@mcp.prompt +def my_prompt() -> PromptMessage: + return PromptMessage( + role="user", + content=TextContent(type="text", text="Hello") + ) +``` + +### After (v3.0) +```python +from fastmcp.prompts import Message + +@mcp.prompt +def my_prompt() -> Message: + return Message("Hello") # role defaults to "user" +``` + +## Type Constraints + +### Prompt Function Return Types +```python +str | list[Message | str] | PromptResult +``` + +**Valid:** +- `return "Hello"` → wrapped as single user Message +- `return [Message("Hi"), Message("Response", role="assistant")]` +- `return ["Hi", "Response"]` → strings auto-wrapped as user Messages +- `return PromptResult(messages=[...], meta={...})` + +**Invalid (now raises error):** +- `return PromptMessage(...)` → Use `Message` instead +- `return Message(...)` as single value → Use `PromptResult([Message(...)])` or return a list + +### Message Class +```python +Message( + content: Any, # Auto-serializes non-str to JSON + role: Literal["user", "assistant"] = "user" +) +``` + +**Auto-Serialization:** +- `str` → passes through as TextContent +- `dict` → JSON-serialized to text +- `list` → JSON-serialized to text +- `BaseModel` → JSON-serialized to text +- `TextContent` / `EmbeddedResource` → passes through directly + +### PromptResult Class +```python +PromptResult( + messages: str | list[Message], # str wrapped as single Message + description: str | None = None, + meta: dict[str, Any] | None = None +) +``` + +## Why This Change? + +1. **Simpler API** - `Message("Hello")` vs `PromptMessage(role="user", content=TextContent(type="text", text="Hello"))` +2. **Auto-serialization** - Dicts/lists/models automatically become JSON +3. **Consistent with resources** - Same pattern as `ResourceContent`/`ResourceResult` +4. **Type safety** - Strict typing catches errors at development time + +## Migration Guide + +### Simple Message +```python +# Before +from mcp.types import PromptMessage, TextContent +return PromptMessage(role="user", content=TextContent(type="text", text="Hello")) + +# After +from fastmcp.prompts import Message +return Message("Hello") +``` + +### Conversation +```python +# Before +return [ + PromptMessage(role="user", content=TextContent(type="text", text="Hi")), + PromptMessage(role="assistant", content=TextContent(type="text", text="Hello!")), +] + +# After +return [ + Message("Hi"), + Message("Hello!", role="assistant"), +] +``` + +### With Metadata +```python +from fastmcp.prompts import Message, PromptResult + +return PromptResult( + messages=[Message("Analyze this")], + meta={"priority": "high"} +) +``` + +## PR + +- #2738 - Introduce Message and PromptResult as canonical prompt types diff --git a/v3-notes/provider-architecture.md b/v3-notes/provider-architecture.md new file mode 100644 index 0000000000..b1a0e80813 --- /dev/null +++ b/v3-notes/provider-architecture.md @@ -0,0 +1,116 @@ +# Provider Architecture: FastMCPProvider + TransformingProvider + +**Version:** 3.0.0 +**Impact:** Breaking change - `MountedProvider` removed + +## Summary + +The monolithic `MountedProvider` was split into two focused, composable components: + +- **`FastMCPProvider`**: Wraps a FastMCP server, exposing its components through the Provider interface +- **`TransformingProvider`**: Wraps any provider to apply namespace prefixes and tool renames + +## Why the Split? + +`MountedProvider` was doing two things: +1. Wrapping a FastMCP server as a provider +2. Transforming component names with prefixes + +Separating these concerns enables: +- Reusing transformations on any provider (not just FastMCP servers) +- Stacking transformations via composition +- Clearer mental model + +## New API + +### FastMCPProvider + +Wraps a FastMCP server to expose it through the Provider interface: + +```python +from fastmcp.server.providers import FastMCPProvider + +sub_server = FastMCP("Sub") + +@sub_server.tool +def greet(name: str) -> str: + return f"Hello, {name}!" + +# Wrap as provider +provider = FastMCPProvider(sub_server) +main_server.add_provider(provider) +``` + +### TransformingProvider + +Wraps any provider to apply transformations: + +```python +# Apply namespace to all components +provider = FastMCPProvider(server).with_namespace("api") +# "my_tool" → "api_my_tool" +# "resource://data" → "resource://api/data" + +# Rename specific tools (bypasses namespace) +provider = FastMCPProvider(server).with_transforms( + namespace="api", + tool_renames={"verbose_tool_name": "short"} +) +# "verbose_tool_name" → "short" +# "other_tool" → "api_other_tool" +``` + +### Stacking Transformations + +Transformations compose via stacking: + +```python +provider = ( + FastMCPProvider(server) + .with_namespace("inner") + .with_namespace("outer") +) +# "tool" → "outer_inner_tool" +``` + +## mount() Uses This Internally + +`FastMCP.mount()` now creates a `FastMCPProvider` + `TransformingProvider` internally: + +```python +main.mount(sub, namespace="api") + +# Equivalent to: +main.add_provider( + FastMCPProvider(sub).with_namespace("api") +) +``` + +## Breaking Changes + +### MountedProvider Removed + +```python +# Before (2.x) +from fastmcp.server.providers import MountedProvider +provider = MountedProvider(server, prefix="api") + +# After (3.x) +from fastmcp.server.providers import FastMCPProvider +provider = FastMCPProvider(server).with_namespace("api") +``` + +### prefix → namespace + +```python +# Before (deprecated) +main.mount(sub, prefix="api") + +# After +main.mount(sub, namespace="api") +``` + +## Implementation PRs + +- #2653 - Split MountedProvider into FastMCPProvider + TransformingProvider +- #2635 - Initial MountedProvider (superseded by #2653) diff --git a/v3-notes/provider-test-pattern.md b/v3-notes/provider-test-pattern.md new file mode 100644 index 0000000000..c37c14c8a2 --- /dev/null +++ b/v3-notes/provider-test-pattern.md @@ -0,0 +1,60 @@ +# Provider Tests: Direct Server Calls + +This document captures the design decision to test providers via direct server method calls rather than wrapping in a Client. + +## Problem + +Provider tests were using the Client pattern: + +```python +async with Client(mcp) as client: + result = await client.call_tool("add", {"x": 1, "y": 2}) + assert result.data == 3 +``` + +This conflated two concerns: +1. Does the provider/server work correctly? +2. Does the Client-Server interaction work correctly? + +Additionally, ~1,200 lines of tests in `test_server_interactions.py` duplicated provider tests. + +## Solution + +Provider tests now call server methods directly: + +```python +result = await mcp.call_tool("add", {"x": 1, "y": 2}) +assert result.structured_content == {"result": 3} +``` + +This establishes clear test ownership: +- **Provider tests** → verify server functionality +- **Integration tests** → verify Client-Server interaction + +## Result Access Patterns + +Direct server calls return canonical FastMCP types, not MCP protocol types: + +| Component | Access Pattern | +|-----------|----------------| +| Tool | `result.structured_content` or `result.text` | +| Resource | `result.contents[0].content` | +| Prompt | `result.messages[0].content.text` | + +## Error Types + +Direct calls raise FastMCP exceptions: +- `NotFoundError` - component not found +- `DisabledError` - component disabled by visibility + +Client calls raise MCP protocol errors (wrapped in `McpError`). + +## Implementation + +- Consolidated duplicate tests from `test_server_interactions.py` into provider test files +- Reduced `test_server_interactions.py` from 1,455 → 179 lines +- Only `TestMeta` tests remain in interactions file (require Client for context injection) + +## PR + +- #2748 - Convert provider tests to use direct server calls diff --git a/v3-notes/task-meta-parameter.md b/v3-notes/task-meta-parameter.md new file mode 100644 index 0000000000..4678c426e5 --- /dev/null +++ b/v3-notes/task-meta-parameter.md @@ -0,0 +1,109 @@ +# Explicit task_meta Parameter for Background Tasks + +This document captures the design decision to add explicit `task_meta` parameters to component execution methods, replacing context variable-based task routing. + +## Problem + +Background task execution used context variables (`_task_metadata`, `_docket_fn_key`) to pass task metadata through the call stack. This was implicit and had several issues: + +1. **Hidden state** - Task metadata flowed through context vars, making it hard to trace +2. **Fragile enrichment** - `fn_key` was enriched in 9 different places (component methods + provider wrappers) +3. **Testing difficulty** - Required setting context vars to test background behavior +4. **No programmatic API** - Users couldn't explicitly request background execution via `call_tool()` + +## Solution + +Add explicit `task_meta: TaskMeta | None` parameters to: +- `FastMCP.call_tool()`, `FastMCP.read_resource()`, `FastMCP.render_prompt()` +- Component methods: `Tool._run()`, `Resource._read()`, `Prompt._render()`, `ResourceTemplate._read()` + +```python +from fastmcp.server.tasks import TaskMeta + +# Explicit background execution +result = await server.call_tool("my_tool", {"arg": "value"}, task_meta=TaskMeta(ttl=300)) + +# Returns CreateTaskResult for background, ToolResult for sync +``` + +## fn_key Enrichment Centralization + +Previously, `fn_key` (the Docket registry key) was set in 9 places: + +**Component methods (5):** +- `Tool._run()` +- `Resource._read()` +- `ResourceTemplate._read()` (2 places) +- `Prompt._render()` + +**Provider wrappers (4):** +- `FastMCPProviderTool._run()` +- `FastMCPProviderResource._read()` +- `FastMCPProviderPrompt._render()` +- `FastMCPProviderResourceTemplate._read()` + +Now, `fn_key` is set in **3 places** (server methods only): + +```python +# In call_tool(), after finding the tool: +if task_meta is not None and task_meta.fn_key is None: + task_meta = replace(task_meta, fn_key=tool.key) + +# In read_resource(), after finding resource or template: +if task_meta is not None and task_meta.fn_key is None: + task_meta = replace(task_meta, fn_key=resource.key) # or template.key + +# In render_prompt(), after finding the prompt: +if task_meta is not None and task_meta.fn_key is None: + task_meta = replace(task_meta, fn_key=prompt.key) +``` + +## Why This Works for Mounted Servers + +For mounted servers, `provider.get_tool(name)` returns a `FastMCPProviderTool` whose `.key` is already namespaced (e.g., `"tool:child_multiply"`). So setting `fn_key = tool.key` in the parent server gives the correct namespaced key. + +When the provider wrapper delegates to the child server, `fn_key` is already set, so the child server won't override it. + +## Type-Safe Overloads + +Each method uses `@overload` to provide correct return types: + +```python +@overload +async def call_tool( + self, name: str, arguments: dict[str, Any], *, task_meta: None = None +) -> ToolResult: ... + +@overload +async def call_tool( + self, name: str, arguments: dict[str, Any], *, task_meta: TaskMeta +) -> ToolResult | mcp.types.CreateTaskResult: ... +``` + +## Middleware Runs Before Docket + +A key fix from #2663: background tasks now properly pass through all middleware stacks before being submitted to Docket. Previously, background task submission bypassed middleware entirely. + +The flow is now: +1. MCP handler extracts task metadata from request +2. Server method (`call_tool`, etc.) finds component via provider +3. Server enriches `task_meta.fn_key` with component key +4. Component's `_run()`/`_read()`/`_render()` is called +5. Middleware runs (logging, auth, rate limiting, etc.) +6. `check_background_task()` submits to Docket if task_meta present + +For mounted servers, the wrapper components delegate to the child server, which runs the child's middleware before the actual execution or Docket submission. + +## Removed Dead Code + +- `_task_metadata` context variable +- `_docket_fn_key` context variable +- `get_task_metadata()` function +- `key` parameter in `check_background_task()` (backwards compat fallback) + +## Implementation PRs + +- #2663 - Components own execution; middleware runs before Docket +- #2749 - `task_meta` for `call_tool()` +- #2750 - `task_meta` for `read_resource()` +- #2751 - `task_meta` for `render_prompt()` + fn_key centralization