Add task_meta to prompts and centralize fn_key enrichment (#2751)

This commit is contained in:
Jeremiah Lowin 2025-12-26 15:04:25 -05:00 committed by GitHub
commit 7b0de84ba7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 597 additions and 181 deletions

View file

@ -5,7 +5,7 @@ from __future__ import annotations as _annotations
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 pydantic import Field
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 @@ class Prompt(FastMCPComponent):
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 @@ class Prompt(FastMCPComponent):
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

View file

@ -5,7 +5,6 @@ from __future__ import annotations
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 @@ class Resource(FastMCPComponent):
"""
from fastmcp.server.tasks.routing import check_background_task
# Enrich task_meta with fn_key if not already set (fallback for programmatic API)
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=self.key)
task_result = await check_background_task(
component=self, task_type="resource", arguments=None, task_meta=task_meta
)

View file

@ -5,7 +5,6 @@ from __future__ import annotations
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 @@ class ResourceTemplate(FastMCPComponent):
"""
from fastmcp.server.tasks.routing import check_background_task
# Enrich task_meta with fn_key if not already set (fallback for programmatic API)
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=self.key)
task_result = await check_background_task(
component=self, task_type="template", arguments=params, task_meta=task_meta
)
@ -346,10 +341,6 @@ class FunctionResourceTemplate(ResourceTemplate):
"""
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
)

View file

@ -40,16 +40,6 @@ _current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar( # type:
"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 @@ __all__ = [
"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."""

View file

@ -13,7 +13,6 @@ from __future__ import annotations
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 @@ class FastMCPProviderTool(Tool):
"""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 @@ class FastMCPProviderResource(Resource):
"""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 @@ class FastMCPProviderPrompt(Prompt):
task_config=prompt.task_config,
)
@overload
async def _render(
self, arguments: dict[str, Any] | None = None
) -> PromptResult | mcp.types.CreateTaskResult:
"""Skip task routing - delegate to render() which calls child middleware.
self,
arguments: dict[str, Any] | None = None,
task_meta: None = None,
) -> PromptResult: ...
The actual underlying prompt will check _task_metadata contextvar and
submit to Docket if appropriate. This wrapper just passes through.
@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:
"""Delegate to child server's render_prompt() with task_meta.
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 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
"""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:

View file

@ -21,6 +21,7 @@ from contextlib import (
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 @@ class FastMCP(Generic[LifespanResultT]):
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 @@ class FastMCP(Generic[LifespanResultT]):
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 @@ class FastMCP(Generic[LifespanResultT]):
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 @@ class FastMCP(Generic[LifespanResultT]):
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 @@ class FastMCP(Generic[LifespanResultT]):
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 @@ class FastMCP(Generic[LifespanResultT]):
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 @@ class FastMCP(Generic[LifespanResultT]):
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 @@ class FastMCP(Generic[LifespanResultT]):
context.message.name,
context.message.arguments,
run_middleware=False,
task_meta=task_meta,
),
)
@ -1385,8 +1423,11 @@ class FastMCP(Generic[LifespanResultT]):
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 @@ class FastMCP(Generic[LifespanResultT]):
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 @@ class FastMCP(Generic[LifespanResultT]):
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 @@ class FastMCP(Generic[LifespanResultT]):
) -> 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 @@ class FastMCP(Generic[LifespanResultT]):
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)
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()
finally:
_task_metadata.reset(task_token)
_docket_fn_key.reset(key_token)
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:

View file

@ -11,7 +11,6 @@ import mcp.types
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 @@ TaskType = Literal["tool", "resource", "template", "prompt"]
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)

View file

@ -3,7 +3,7 @@ from __future__ import annotations
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 @@ class Tool(FastMCPComponent):
"""
from fastmcp.server.tasks.routing import check_background_task
# Enrich task_meta with fn_key if not already set (fallback for programmatic API)
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=self.key)
task_result = await check_background_task(
component=self,
task_type="tool",

View file

@ -10,7 +10,7 @@ from mcp.types import TextContent
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 @@ class TestPromptDecorator:
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 @@ class TestPromptDecorator:
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 @@ class TestPromptDecorator:
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 @@ class TestPromptDecorator:
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 @@ class TestPromptDecorator:
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 @@ class TestPromptDecorator:
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 @@ class TestPromptDecorator:
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 @@ class TestPromptDecorator:
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 @@ class TestPromptDecorator:
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 @@ class TestPromptDecorator:
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)

View file

@ -10,7 +10,7 @@ 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.resource import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.tasks.config import TaskMeta
@ -28,7 +28,6 @@ class TestResourceTaskMetaParameter:
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 @@ class TestResourceTaskMetaParameter:
# 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 @@ class TestResourceTemplateTaslMeta:
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 @@ class TestResourceTemplateTaslMeta:
# 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):

View file

@ -805,7 +805,6 @@ class TestMountedTasksWithTaskMetaParameter:
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 @@ class TestMountedTasksWithTaskMetaParameter:
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 @@ class TestMountedTasksWithTaskMetaParameter:
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 @@ class TestMountedTasksWithTaskMetaParameter:
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 @@ class TestMountedTasksWithTaskMetaParameter:
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:

View file

@ -8,7 +8,6 @@ from mcp.types import TextContent, TextResourceContents
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 test_prompt_with_dependency(mcp: FastMCP):
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 test_async_prompt_context_manager_stays_open(mcp: FastMCP):
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 test_sync_prompt_context_manager_stays_open(mcp: FastMCP):
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 test_prompt_dependency_cannot_be_overridden_externally(mcp: FastMCP):
# 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 test_prompt_dependency_cannot_be_overridden_externally(mcp: FastMCP):
"secure_prompt",
{"topic": "test", "secret": "HACKED"}, # Attempt override
)
assert isinstance(result, PromptResult)
message = result.messages[0]
content = message.content
assert isinstance(content, TextContent)

View file

@ -9,7 +9,6 @@ from fastmcp import FastMCP
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 @@ class TestBasicMount:
# 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 @@ class TestPrefixConflictResolution:
# 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 @@ class TestPrefixConflictResolution:
# 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 @@ class TestPrompts:
# 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 @@ class TestPrompts:
# 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 @@ class TestProxyServer:
# 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 @@ class TestDeeplyNestedMount:
# 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

View file

@ -7,7 +7,7 @@ import pytest
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 @@ class TestProviderExecutionMethods:
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!"

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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