mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Add task_meta to prompts and centralize fn_key enrichment (#2751)
This commit is contained in:
parent
fc19f1f8de
commit
7b0de84ba7
18 changed files with 597 additions and 181 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
|
|
|
|||
121
v3-notes/prompt-internal-types.md
Normal file
121
v3-notes/prompt-internal-types.md
Normal 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
|
||||
116
v3-notes/provider-architecture.md
Normal file
116
v3-notes/provider-architecture.md
Normal 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)
|
||||
60
v3-notes/provider-test-pattern.md
Normal file
60
v3-notes/provider-test-pattern.md
Normal 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
|
||||
109
v3-notes/task-meta-parameter.md
Normal file
109
v3-notes/task-meta-parameter.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue