mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Consolidate execution method chains into single public API (#2728)
This commit is contained in:
parent
1d17334803
commit
b05801d66f
11 changed files with 540 additions and 489 deletions
|
|
@ -100,7 +100,7 @@ def make_endpoint(action, component, config):
|
|||
except NotFoundError as e:
|
||||
raise StarletteHTTPException(
|
||||
status_code=404,
|
||||
detail=f"Unknown {component}: {name}",
|
||||
detail=f"Unknown {component}: {name!r}",
|
||||
) from e
|
||||
|
||||
return endpoint
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class ComponentService:
|
|||
self._server.enable(keys=[Tool.make_key(name)])
|
||||
tool = await self._server.get_tool(name)
|
||||
if tool is None:
|
||||
raise NotFoundError(f"Unknown tool: {name}")
|
||||
raise NotFoundError(f"Unknown tool: {name!r}")
|
||||
return tool
|
||||
|
||||
# 2. Check mounted servers via FastMCPProvider/TransformingProvider
|
||||
|
|
@ -85,7 +85,7 @@ class ComponentService:
|
|||
mounted_service = ComponentService(server)
|
||||
tool = await mounted_service._enable_tool(unprefixed)
|
||||
return tool
|
||||
raise NotFoundError(f"Unknown tool: {name}")
|
||||
raise NotFoundError(f"Unknown tool: {name!r}")
|
||||
|
||||
async def _disable_tool(self, name: str) -> Tool:
|
||||
"""Handle 'disableTool' requests.
|
||||
|
|
@ -103,7 +103,7 @@ class ComponentService:
|
|||
if key in self._server._local_provider._components:
|
||||
tool = self._server._local_provider._components[key]
|
||||
if not isinstance(tool, Tool):
|
||||
raise NotFoundError(f"Unknown tool: {name}")
|
||||
raise NotFoundError(f"Unknown tool: {name!r}")
|
||||
self._server.disable(keys=[key])
|
||||
return tool
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ class ComponentService:
|
|||
mounted_service = ComponentService(server)
|
||||
tool = await mounted_service._disable_tool(unprefixed)
|
||||
return tool
|
||||
raise NotFoundError(f"Unknown tool: {name}")
|
||||
raise NotFoundError(f"Unknown tool: {name!r}")
|
||||
|
||||
async def _enable_resource(self, uri: str) -> Resource | ResourceTemplate:
|
||||
"""Handle 'enableResource' requests.
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ class MiddlewareServerSession(ServerSession):
|
|||
)
|
||||
|
||||
try:
|
||||
return await self.fastmcp._apply_middleware(
|
||||
return await self.fastmcp._run_middleware(
|
||||
mw_context, call_original_handler
|
||||
)
|
||||
except McpError as e:
|
||||
|
|
|
|||
|
|
@ -97,20 +97,19 @@ class FastMCPProviderTool(Tool):
|
|||
async def run(
|
||||
self, arguments: dict[str, Any]
|
||||
) -> ToolResult | mcp.types.CreateTaskResult: # type: ignore[override]
|
||||
"""Delegate to child server's middleware chain.
|
||||
"""Delegate to child server's call_tool().
|
||||
|
||||
This runs BEFORE any backgrounding decision - the actual underlying
|
||||
tool will check contextvars and submit to Docket if appropriate.
|
||||
"""
|
||||
return await self._server._call_tool_middleware(self._original_name, arguments)
|
||||
return await self._server.call_tool(self._original_name, arguments)
|
||||
|
||||
|
||||
class FastMCPProviderResource(Resource):
|
||||
"""Resource that delegates reading to a wrapped server's middleware.
|
||||
"""Resource that delegates reading to a wrapped server's read_resource().
|
||||
|
||||
When `read()` is called, this resource invokes the wrapped server's
|
||||
`_read_resource_middleware()` method, ensuring the server's middleware
|
||||
chain is executed.
|
||||
`read_resource()` method, ensuring the server's middleware chain is executed.
|
||||
"""
|
||||
|
||||
_server: Any = None # FastMCP, but Any to avoid circular import
|
||||
|
|
@ -150,10 +149,7 @@ class FastMCPProviderResource(Resource):
|
|||
return await self.read()
|
||||
|
||||
async def read(self) -> ResourceContent | mcp.types.CreateTaskResult: # type: ignore[override]
|
||||
"""Delegate to child server's middleware.
|
||||
|
||||
When called from a Docket worker (background task), there's no FastMCP
|
||||
context set up, so we create one for the child server.
|
||||
"""Delegate to child server's read_resource().
|
||||
|
||||
Note: The _docket_fn_key contextvar is intentionally NOT updated here.
|
||||
The parent set it to the full namespaced key (e.g., data://c/gc/value)
|
||||
|
|
@ -161,33 +157,17 @@ class FastMCPProviderResource(Resource):
|
|||
layers pass this through unchanged so the eventual resource._read()
|
||||
uses the correct Docket lookup key.
|
||||
"""
|
||||
import fastmcp.server.context
|
||||
|
||||
try:
|
||||
from fastmcp.server.dependencies import get_context
|
||||
|
||||
get_context() # Will raise if no context
|
||||
result = await self._server._read_resource_middleware(self._original_uri)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return result[0]
|
||||
except RuntimeError:
|
||||
# No context (e.g., Docket worker) - create one for the child server
|
||||
async with fastmcp.server.context.Context(fastmcp=self._server):
|
||||
result = await self._server._read_resource_middleware(
|
||||
self._original_uri
|
||||
)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return result[0]
|
||||
result = await self._server.read_resource(self._original_uri)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return result[0]
|
||||
|
||||
|
||||
class FastMCPProviderPrompt(Prompt):
|
||||
"""Prompt that delegates rendering to a wrapped server's middleware.
|
||||
"""Prompt that delegates rendering to a wrapped server's render_prompt().
|
||||
|
||||
When `render()` is called, this prompt invokes the wrapped server's
|
||||
`_get_prompt_content_middleware()` method, ensuring the server's middleware
|
||||
chain is executed.
|
||||
`render_prompt()` method, ensuring the server's middleware chain is executed.
|
||||
"""
|
||||
|
||||
_server: Any = None # FastMCP, but Any to avoid circular import
|
||||
|
|
@ -229,10 +209,7 @@ class FastMCPProviderPrompt(Prompt):
|
|||
async def render(
|
||||
self, arguments: dict[str, Any] | None = None
|
||||
) -> PromptResult | mcp.types.CreateTaskResult: # type: ignore[override]
|
||||
"""Delegate to child server's middleware.
|
||||
|
||||
When called from a Docket worker (background task), there's no FastMCP
|
||||
context set up, so we create one for the child server.
|
||||
"""Delegate to child server's render_prompt().
|
||||
|
||||
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
|
||||
|
|
@ -240,27 +217,7 @@ class FastMCPProviderPrompt(Prompt):
|
|||
pass this through unchanged so the eventual prompt._render() uses the
|
||||
correct Docket lookup key.
|
||||
"""
|
||||
import fastmcp.server.context
|
||||
|
||||
try:
|
||||
from fastmcp.server.dependencies import get_context
|
||||
|
||||
get_context() # Will raise if no context
|
||||
result = await self._server._get_prompt_content_middleware(
|
||||
self._original_name, arguments
|
||||
)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return result
|
||||
except RuntimeError:
|
||||
# No context (e.g., Docket worker) - create one for the child server
|
||||
async with fastmcp.server.context.Context(fastmcp=self._server):
|
||||
result = await self._server._get_prompt_content_middleware(
|
||||
self._original_name, arguments
|
||||
)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return result
|
||||
return await self._server.render_prompt(self._original_name, arguments)
|
||||
|
||||
|
||||
class FastMCPProviderResourceTemplate(ResourceTemplate):
|
||||
|
|
@ -323,7 +280,7 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
|
|||
async def _read(
|
||||
self, uri: str, params: dict[str, Any]
|
||||
) -> ResourceContent | mcp.types.CreateTaskResult:
|
||||
"""Delegate to child server's middleware.
|
||||
"""Delegate to child server's read_resource().
|
||||
|
||||
Skips task routing at this layer - the child's template._read() will
|
||||
check _task_metadata contextvar and submit to Docket if appropriate.
|
||||
|
|
@ -335,7 +292,6 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
|
|||
Only sets _docket_fn_key if not already set - in nested mounts, the
|
||||
outermost wrapper sets the key and inner wrappers preserve it.
|
||||
"""
|
||||
import fastmcp.server.context
|
||||
from fastmcp.server.dependencies import _docket_fn_key
|
||||
|
||||
# Expand the original template with params to get internal URI
|
||||
|
|
@ -352,21 +308,10 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
|
|||
if not existing_key or "{" not in existing_key:
|
||||
key_token = _docket_fn_key.set(self.key)
|
||||
try:
|
||||
try:
|
||||
from fastmcp.server.dependencies import get_context
|
||||
|
||||
get_context() # Will raise if no context
|
||||
result = await self._server._read_resource_middleware(original_uri)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return result[0]
|
||||
except RuntimeError:
|
||||
# No context (e.g., Docket worker) - create one for the child server
|
||||
async with fastmcp.server.context.Context(fastmcp=self._server):
|
||||
result = await self._server._read_resource_middleware(original_uri)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return result[0]
|
||||
result = await self._server.read_resource(original_uri)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return result[0]
|
||||
finally:
|
||||
if key_token is not None:
|
||||
_docket_fn_key.reset(key_token)
|
||||
|
|
@ -481,7 +426,7 @@ class FastMCPProvider(Provider):
|
|||
each tool as a FastMCPProviderTool that delegates execution to the
|
||||
nested server's middleware.
|
||||
"""
|
||||
raw_tools = await self.server.get_tools(apply_middleware=True)
|
||||
raw_tools = await self.server.get_tools(run_middleware=True)
|
||||
return [FastMCPProviderTool.wrap(self.server, t) for t in raw_tools]
|
||||
|
||||
async def get_tool(self, name: str) -> Tool | None:
|
||||
|
|
@ -500,7 +445,7 @@ class FastMCPProvider(Provider):
|
|||
each resource as a FastMCPProviderResource that delegates reading to the
|
||||
nested server's middleware.
|
||||
"""
|
||||
raw_resources = await self.server.get_resources(apply_middleware=True)
|
||||
raw_resources = await self.server.get_resources(run_middleware=True)
|
||||
return [FastMCPProviderResource.wrap(self.server, r) for r in raw_resources]
|
||||
|
||||
async def get_resource(self, uri: str) -> Resource | None:
|
||||
|
|
@ -518,7 +463,7 @@ class FastMCPProvider(Provider):
|
|||
Returns FastMCPProviderResourceTemplate instances that create
|
||||
FastMCPProviderResources when materialized.
|
||||
"""
|
||||
raw_templates = await self.server.get_resource_templates(apply_middleware=True)
|
||||
raw_templates = await self.server.get_resource_templates(run_middleware=True)
|
||||
return [
|
||||
FastMCPProviderResourceTemplate.wrap(self.server, t) for t in raw_templates
|
||||
]
|
||||
|
|
@ -541,7 +486,7 @@ class FastMCPProvider(Provider):
|
|||
Returns FastMCPProviderPrompt instances that delegate rendering to the
|
||||
wrapped server's middleware.
|
||||
"""
|
||||
raw_prompts = await self.server.get_prompts(apply_middleware=True)
|
||||
raw_prompts = await self.server.get_prompts(run_middleware=True)
|
||||
return [FastMCPProviderPrompt.wrap(self.server, p) for p in raw_prompts]
|
||||
|
||||
async def get_prompt(self, name: str) -> Prompt | None:
|
||||
|
|
|
|||
|
|
@ -692,7 +692,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
self._mcp_server.request_handlers[ListTasksRequest] = handle_list_tasks
|
||||
self._mcp_server.request_handlers[CancelTaskRequest] = handle_cancel_task
|
||||
|
||||
async def _apply_middleware(
|
||||
async def _run_middleware(
|
||||
self,
|
||||
context: MiddlewareContext[Any],
|
||||
call_next: Callable[[MiddlewareContext[Any]], Awaitable[Any]],
|
||||
|
|
@ -790,17 +790,17 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
"""Check if a component is enabled (not in blocklist, passes allowlist)."""
|
||||
return self._visibility.is_enabled(component)
|
||||
|
||||
async def get_tools(self, *, apply_middleware: bool = False) -> list[Tool]:
|
||||
async def get_tools(self, *, run_middleware: bool = False) -> list[Tool]:
|
||||
"""Get all enabled tools from providers.
|
||||
|
||||
Queries all providers in parallel and collects tools.
|
||||
First provider wins for duplicate keys. Filters by server blocklist.
|
||||
|
||||
Args:
|
||||
apply_middleware: If True, apply the middleware chain before
|
||||
run_middleware: If True, apply the middleware chain before
|
||||
returning results. Used by MCP handlers and mounted servers.
|
||||
"""
|
||||
if apply_middleware:
|
||||
if run_middleware:
|
||||
async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
|
||||
mw_context = MiddlewareContext(
|
||||
message=mcp.types.ListToolsRequest(method="tools/list"),
|
||||
|
|
@ -810,11 +810,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
fastmcp_context=fastmcp_ctx,
|
||||
)
|
||||
return list(
|
||||
await self._apply_middleware(
|
||||
await self._run_middleware(
|
||||
context=mw_context,
|
||||
call_next=lambda context: self.get_tools(
|
||||
apply_middleware=False
|
||||
),
|
||||
call_next=lambda context: self.get_tools(run_middleware=False),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -857,7 +855,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
if isinstance(result, Tool) and self._is_component_enabled(result):
|
||||
return result
|
||||
|
||||
raise NotFoundError(f"Unknown tool: {name}")
|
||||
raise NotFoundError(f"Unknown tool: {name!r}")
|
||||
|
||||
async def _get_resource_or_template_or_none(
|
||||
self, uri: str
|
||||
|
|
@ -889,17 +887,17 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
return None
|
||||
|
||||
async def get_resources(self, *, apply_middleware: bool = False) -> list[Resource]:
|
||||
async def get_resources(self, *, run_middleware: bool = False) -> list[Resource]:
|
||||
"""Get all enabled resources from providers.
|
||||
|
||||
Queries all providers in parallel and collects resources.
|
||||
First provider wins for duplicate keys. Filters by server blocklist.
|
||||
|
||||
Args:
|
||||
apply_middleware: If True, apply the middleware chain before
|
||||
run_middleware: If True, apply the middleware chain before
|
||||
returning results. Used by MCP handlers and mounted servers.
|
||||
"""
|
||||
if apply_middleware:
|
||||
if run_middleware:
|
||||
async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
|
||||
mw_context = MiddlewareContext(
|
||||
message={}, # List resources doesn't have parameters
|
||||
|
|
@ -909,10 +907,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
fastmcp_context=fastmcp_ctx,
|
||||
)
|
||||
return list(
|
||||
await self._apply_middleware(
|
||||
await self._run_middleware(
|
||||
context=mw_context,
|
||||
call_next=lambda context: self.get_resources(
|
||||
apply_middleware=False
|
||||
run_middleware=False
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
@ -962,7 +960,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
raise NotFoundError(f"Unknown resource: {uri}")
|
||||
|
||||
async def get_resource_templates(
|
||||
self, *, apply_middleware: bool = False
|
||||
self, *, run_middleware: bool = False
|
||||
) -> list[ResourceTemplate]:
|
||||
"""Get all enabled resource templates from providers.
|
||||
|
||||
|
|
@ -970,10 +968,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
First provider wins for duplicate keys. Filters by server blocklist.
|
||||
|
||||
Args:
|
||||
apply_middleware: If True, apply the middleware chain before
|
||||
run_middleware: If True, apply the middleware chain before
|
||||
returning results. Used by MCP handlers and mounted servers.
|
||||
"""
|
||||
if apply_middleware:
|
||||
if run_middleware:
|
||||
async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
|
||||
mw_context = MiddlewareContext(
|
||||
message={}, # List resource templates doesn't have parameters
|
||||
|
|
@ -983,10 +981,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
fastmcp_context=fastmcp_ctx,
|
||||
)
|
||||
return list(
|
||||
await self._apply_middleware(
|
||||
await self._run_middleware(
|
||||
context=mw_context,
|
||||
call_next=lambda context: self.get_resource_templates(
|
||||
apply_middleware=False
|
||||
run_middleware=False
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
@ -1039,17 +1037,17 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
raise NotFoundError(f"Unknown resource template: {uri}")
|
||||
|
||||
async def get_prompts(self, *, apply_middleware: bool = False) -> list[Prompt]:
|
||||
async def get_prompts(self, *, run_middleware: bool = False) -> list[Prompt]:
|
||||
"""Get all enabled prompts from providers.
|
||||
|
||||
Queries all providers in parallel and collects prompts.
|
||||
First provider wins for duplicate keys. Filters by server blocklist.
|
||||
|
||||
Args:
|
||||
apply_middleware: If True, apply the middleware chain before
|
||||
run_middleware: If True, apply the middleware chain before
|
||||
returning results. Used by MCP handlers and mounted servers.
|
||||
"""
|
||||
if apply_middleware:
|
||||
if run_middleware:
|
||||
async with fastmcp.server.context.Context(fastmcp=self) as fastmcp_ctx:
|
||||
mw_context = MiddlewareContext(
|
||||
message=mcp.types.ListPromptsRequest(method="prompts/list"),
|
||||
|
|
@ -1059,10 +1057,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
fastmcp_context=fastmcp_ctx,
|
||||
)
|
||||
return list(
|
||||
await self._apply_middleware(
|
||||
await self._run_middleware(
|
||||
context=mw_context,
|
||||
call_next=lambda context: self.get_prompts(
|
||||
apply_middleware=False
|
||||
run_middleware=False
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
@ -1142,6 +1140,233 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
raise NotFoundError(f"Unknown component: {key}")
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
run_middleware: bool = True,
|
||||
) -> ToolResult | mcp.types.CreateTaskResult:
|
||||
"""Call a tool by name.
|
||||
|
||||
This is the public API for executing tools. By default, middleware is applied.
|
||||
|
||||
Args:
|
||||
name: The tool name
|
||||
arguments: Tool arguments (optional)
|
||||
run_middleware: If True (default), apply the middleware chain.
|
||||
Set to False when called from middleware to avoid re-applying.
|
||||
|
||||
Returns:
|
||||
ToolResult with content and optional structured_content.
|
||||
May return CreateTaskResult if called in MCP context with task metadata.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If tool not found or disabled
|
||||
ToolError: If tool execution fails
|
||||
ValidationError: If arguments fail validation
|
||||
"""
|
||||
async with fastmcp.server.context.Context(fastmcp=self) as ctx:
|
||||
if run_middleware:
|
||||
mw_context = MiddlewareContext[CallToolRequestParams](
|
||||
message=mcp.types.CallToolRequestParams(
|
||||
name=name, arguments=arguments or {}
|
||||
),
|
||||
source="client",
|
||||
type="request",
|
||||
method="tools/call",
|
||||
fastmcp_context=ctx,
|
||||
)
|
||||
return await self._run_middleware(
|
||||
context=mw_context,
|
||||
call_next=lambda context: self.call_tool(
|
||||
context.message.name,
|
||||
context.message.arguments or {},
|
||||
run_middleware=False,
|
||||
),
|
||||
)
|
||||
|
||||
# Core logic: find and execute tool
|
||||
for provider in self._providers:
|
||||
tool = await provider.get_tool(name)
|
||||
if tool is not None and self._is_component_enabled(tool):
|
||||
try:
|
||||
return await tool._run(arguments or {})
|
||||
except FastMCPError:
|
||||
logger.exception(f"Error calling tool {name!r}")
|
||||
raise
|
||||
except (ValidationError, PydanticValidationError):
|
||||
logger.exception(f"Error validating tool {name!r}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error calling tool {name!r}")
|
||||
if self._mask_error_details:
|
||||
raise ToolError(f"Error calling tool {name!r}") from e
|
||||
raise ToolError(f"Error calling tool {name!r}: {e}") from e
|
||||
|
||||
raise NotFoundError(f"Unknown tool: {name!r}")
|
||||
|
||||
async def read_resource(
|
||||
self,
|
||||
uri: str,
|
||||
*,
|
||||
run_middleware: bool = True,
|
||||
) -> list[ResourceContent] | mcp.types.CreateTaskResult:
|
||||
"""Read a resource by URI.
|
||||
|
||||
This is the public API for reading resources. By default, middleware is applied.
|
||||
Checks concrete resources first, then templates.
|
||||
|
||||
Args:
|
||||
uri: The resource URI
|
||||
run_middleware: If True (default), apply the middleware chain.
|
||||
Set to False when called from middleware to avoid re-applying.
|
||||
|
||||
Returns:
|
||||
List of ResourceContent objects.
|
||||
May return CreateTaskResult if called in MCP context with task metadata.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If resource not found or disabled
|
||||
ResourceError: If resource read fails
|
||||
"""
|
||||
async with fastmcp.server.context.Context(fastmcp=self) as ctx:
|
||||
if run_middleware:
|
||||
uri_param = AnyUrl(uri)
|
||||
mw_context = MiddlewareContext(
|
||||
message=mcp.types.ReadResourceRequestParams(uri=uri_param),
|
||||
source="client",
|
||||
type="request",
|
||||
method="resources/read",
|
||||
fastmcp_context=ctx,
|
||||
)
|
||||
result = await self._run_middleware(
|
||||
context=mw_context,
|
||||
call_next=lambda context: self.read_resource(
|
||||
str(context.message.uri),
|
||||
run_middleware=False,
|
||||
),
|
||||
)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return list(result)
|
||||
|
||||
# Core logic: find and read resource
|
||||
# First pass: try concrete resources from all providers
|
||||
for provider in self._providers:
|
||||
resource = await provider.get_resource(uri)
|
||||
if resource is not None and self._is_component_enabled(resource):
|
||||
try:
|
||||
result = await resource._read()
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
if result.mime_type is None:
|
||||
result.mime_type = resource.mime_type
|
||||
return [result]
|
||||
except (FastMCPError, McpError):
|
||||
logger.exception(f"Error reading resource {uri!r}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error reading resource {uri!r}")
|
||||
if self._mask_error_details:
|
||||
raise ResourceError(
|
||||
f"Error reading resource {uri!r}"
|
||||
) from e
|
||||
raise ResourceError(
|
||||
f"Error reading resource {uri!r}: {e}"
|
||||
) from e
|
||||
|
||||
# Second pass: try templates from all providers
|
||||
for provider in self._providers:
|
||||
template = await provider.get_resource_template(uri)
|
||||
if template is not None and self._is_component_enabled(template):
|
||||
params = template.matches(uri)
|
||||
if params is not None:
|
||||
try:
|
||||
result = await template._read(uri, params)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return [result]
|
||||
except (FastMCPError, McpError):
|
||||
logger.exception(f"Error reading resource {uri!r}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error reading resource {uri!r}")
|
||||
if self._mask_error_details:
|
||||
raise ResourceError(
|
||||
f"Error reading resource {uri!r}"
|
||||
) from e
|
||||
raise ResourceError(
|
||||
f"Error reading resource {uri!r}: {e}"
|
||||
) from e
|
||||
|
||||
raise NotFoundError(f"Unknown resource: {uri!r}")
|
||||
|
||||
async def render_prompt(
|
||||
self,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
run_middleware: bool = True,
|
||||
) -> PromptResult | mcp.types.CreateTaskResult:
|
||||
"""Render a prompt by name.
|
||||
|
||||
This is the public API for rendering prompts. By default, middleware is applied.
|
||||
Use get_prompt() to retrieve the prompt definition without rendering.
|
||||
|
||||
Args:
|
||||
name: The prompt name
|
||||
arguments: Prompt arguments (optional)
|
||||
run_middleware: If True (default), apply the middleware chain.
|
||||
Set to False when called from middleware to avoid re-applying.
|
||||
|
||||
Returns:
|
||||
PromptResult with messages and optional description.
|
||||
May return CreateTaskResult if called in MCP context with task metadata.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If prompt not found or disabled
|
||||
PromptError: If prompt rendering fails
|
||||
"""
|
||||
async with fastmcp.server.context.Context(fastmcp=self) as ctx:
|
||||
if run_middleware:
|
||||
mw_context = MiddlewareContext(
|
||||
message=mcp.types.GetPromptRequestParams(
|
||||
name=name, arguments=arguments
|
||||
),
|
||||
source="client",
|
||||
type="request",
|
||||
method="prompts/get",
|
||||
fastmcp_context=ctx,
|
||||
)
|
||||
return await self._run_middleware(
|
||||
context=mw_context,
|
||||
call_next=lambda context: self.render_prompt(
|
||||
context.message.name,
|
||||
context.message.arguments,
|
||||
run_middleware=False,
|
||||
),
|
||||
)
|
||||
|
||||
# Core logic: find and render prompt
|
||||
for provider in self._providers:
|
||||
prompt = await provider.get_prompt(name)
|
||||
if prompt is not None and self._is_component_enabled(prompt):
|
||||
try:
|
||||
return await prompt._render(arguments)
|
||||
except (FastMCPError, McpError):
|
||||
logger.exception(f"Error rendering prompt {name!r}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error rendering prompt {name!r}")
|
||||
if self._mask_error_details:
|
||||
raise PromptError(f"Error rendering prompt {name!r}") from e
|
||||
raise PromptError(
|
||||
f"Error rendering prompt {name!r}: {e}"
|
||||
) from e
|
||||
|
||||
raise NotFoundError(f"Unknown prompt: {name!r}")
|
||||
|
||||
def custom_route(
|
||||
self,
|
||||
path: str,
|
||||
|
|
@ -1211,7 +1436,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
logger.debug(f"[{self.name}] Handler called: list_tools")
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=self):
|
||||
tools = await self.get_tools(apply_middleware=True)
|
||||
tools = await self.get_tools(run_middleware=True)
|
||||
return [
|
||||
tool.to_mcp_tool(
|
||||
name=tool.name,
|
||||
|
|
@ -1228,7 +1453,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
logger.debug(f"[{self.name}] Handler called: list_resources")
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=self):
|
||||
resources = await self.get_resources(apply_middleware=True)
|
||||
resources = await self.get_resources(run_middleware=True)
|
||||
return [
|
||||
resource.to_mcp_resource(
|
||||
uri=str(resource.uri),
|
||||
|
|
@ -1245,7 +1470,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
logger.debug(f"[{self.name}] Handler called: list_resource_templates")
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=self):
|
||||
templates = await self.get_resource_templates(apply_middleware=True)
|
||||
templates = await self.get_resource_templates(run_middleware=True)
|
||||
return [
|
||||
template.to_mcp_template(
|
||||
uriTemplate=template.uri_template,
|
||||
|
|
@ -1262,7 +1487,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
logger.debug(f"[{self.name}] Handler called: list_prompts")
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=self):
|
||||
prompts = await self.get_prompts(apply_middleware=True)
|
||||
prompts = await self.get_prompts(run_middleware=True)
|
||||
return [
|
||||
prompt.to_mcp_prompt(
|
||||
name=prompt.name,
|
||||
|
|
@ -1282,7 +1507,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
"""
|
||||
Handle MCP 'callTool' requests.
|
||||
|
||||
Sets task metadata contextvar and runs middleware. The tool's _run() method
|
||||
Sets task metadata contextvar and calls call_tool(). The tool's _run() method
|
||||
handles the backgrounding decision, ensuring middleware runs before Docket.
|
||||
|
||||
Args:
|
||||
|
|
@ -1298,37 +1523,34 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
f"[{self.name}] Handler called: call_tool %s with %s", key, arguments
|
||||
)
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=self):
|
||||
try:
|
||||
# Extract SEP-1686 task metadata from request context
|
||||
task_meta_dict: dict[str, Any] | None = None
|
||||
try:
|
||||
# Extract SEP-1686 task metadata from request context
|
||||
task_meta_dict: dict[str, Any] | 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)
|
||||
except (AttributeError, LookupError):
|
||||
pass
|
||||
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)
|
||||
except (AttributeError, LookupError):
|
||||
pass
|
||||
|
||||
# Set contextvars so tool._run() can access them
|
||||
task_token = _task_metadata.set(task_meta_dict)
|
||||
key_token = _docket_fn_key.set(Tool.make_key(key))
|
||||
try:
|
||||
# Middleware always runs - tool._run() handles backgrounding
|
||||
result = await self._call_tool_middleware(key, arguments)
|
||||
# Set contextvars so tool._run() can access them
|
||||
task_token = _task_metadata.set(task_meta_dict)
|
||||
key_token = _docket_fn_key.set(Tool.make_key(key))
|
||||
try:
|
||||
result = await self.call_tool(key, arguments)
|
||||
|
||||
# Result could be CreateTaskResult (from nested tool._run())
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return result.to_mcp_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_result()
|
||||
finally:
|
||||
_task_metadata.reset(task_token)
|
||||
_docket_fn_key.reset(key_token)
|
||||
|
||||
except DisabledError as e:
|
||||
raise NotFoundError(f"Unknown tool: {key}") from e
|
||||
except NotFoundError as e:
|
||||
raise NotFoundError(f"Unknown tool: {key}") from e
|
||||
except DisabledError as e:
|
||||
raise NotFoundError(f"Unknown tool: {key!r}") from e
|
||||
except NotFoundError as e:
|
||||
raise NotFoundError(f"Unknown tool: {key!r}") from e
|
||||
|
||||
async def _read_resource_handler(
|
||||
self, req: mcp.types.ReadResourceRequest
|
||||
|
|
@ -1352,34 +1574,29 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
except (AttributeError, LookupError):
|
||||
pass
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=self):
|
||||
try:
|
||||
# Set contextvars so Resource._read() can access them
|
||||
task_token = _task_metadata.set(task_meta_dict)
|
||||
key_token = _docket_fn_key.set(Resource.make_key(str(uri)))
|
||||
try:
|
||||
# Set contextvars so Resource._read() can access them
|
||||
task_token = _task_metadata.set(task_meta_dict)
|
||||
key_token = _docket_fn_key.set(Resource.make_key(str(uri)))
|
||||
try:
|
||||
# Middleware always runs - Resource._read() handles backgrounding
|
||||
result = await self._read_resource_middleware(uri)
|
||||
result = await self.read_resource(str(uri))
|
||||
|
||||
# Result could be CreateTaskResult (from nested Resource._read())
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return mcp.types.ServerResult(result)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return mcp.types.ServerResult(result)
|
||||
|
||||
# Normal synchronous result
|
||||
mcp_contents = [
|
||||
item.to_mcp_resource_contents(uri) for item in result
|
||||
]
|
||||
return mcp.types.ServerResult(
|
||||
mcp.types.ReadResourceResult(contents=mcp_contents)
|
||||
)
|
||||
finally:
|
||||
_task_metadata.reset(task_token)
|
||||
_docket_fn_key.reset(key_token)
|
||||
# Normal synchronous result
|
||||
mcp_contents = [item.to_mcp_resource_contents(uri) for item in result]
|
||||
return mcp.types.ServerResult(
|
||||
mcp.types.ReadResourceResult(contents=mcp_contents)
|
||||
)
|
||||
finally:
|
||||
_task_metadata.reset(task_token)
|
||||
_docket_fn_key.reset(key_token)
|
||||
|
||||
except DisabledError as e:
|
||||
raise NotFoundError(f"Unknown resource: {str(uri)!r}") from e
|
||||
except NotFoundError as e:
|
||||
raise NotFoundError(f"Unknown resource: {str(uri)!r}") from e
|
||||
except DisabledError as e:
|
||||
raise NotFoundError(f"Unknown resource: {str(uri)!r}") from e
|
||||
except NotFoundError as e:
|
||||
raise NotFoundError(f"Unknown resource: {str(uri)!r}") from e
|
||||
|
||||
async def _get_prompt_handler(
|
||||
self, req: mcp.types.GetPromptRequest
|
||||
|
|
@ -1404,338 +1621,73 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
except (AttributeError, LookupError):
|
||||
pass
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=self):
|
||||
try:
|
||||
# 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:
|
||||
# Middleware always runs - Prompt._render() handles backgrounding
|
||||
result = await self._get_prompt_content_middleware(name, arguments)
|
||||
|
||||
# Result could be CreateTaskResult (from nested Prompt._render())
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return mcp.types.ServerResult(result)
|
||||
|
||||
# Normal synchronous result
|
||||
return mcp.types.ServerResult(result.to_mcp_prompt_result())
|
||||
finally:
|
||||
_task_metadata.reset(task_token)
|
||||
_docket_fn_key.reset(key_token)
|
||||
|
||||
except DisabledError as e:
|
||||
raise NotFoundError(f"Unknown prompt: {name!r}") from e
|
||||
except NotFoundError as e:
|
||||
raise NotFoundError(f"Unknown prompt: {name!r}") from e
|
||||
|
||||
async def _call_tool_middleware(
|
||||
self,
|
||||
key: str,
|
||||
arguments: dict[str, Any],
|
||||
) -> ToolResult | mcp.types.CreateTaskResult:
|
||||
"""
|
||||
Applies this server's middleware and delegates the filtered call to the manager.
|
||||
|
||||
Returns ToolResult for synchronous execution, or CreateTaskResult if the
|
||||
tool was submitted to Docket for background execution.
|
||||
"""
|
||||
|
||||
mw_context = MiddlewareContext[CallToolRequestParams](
|
||||
message=mcp.types.CallToolRequestParams(name=key, arguments=arguments),
|
||||
source="client",
|
||||
type="request",
|
||||
method="tools/call",
|
||||
fastmcp_context=fastmcp.server.dependencies.get_context(),
|
||||
)
|
||||
return await self._apply_middleware(
|
||||
context=mw_context, call_next=self._call_tool
|
||||
)
|
||||
|
||||
async def _call_tool(
|
||||
self,
|
||||
context: MiddlewareContext[mcp.types.CallToolRequestParams],
|
||||
) -> ToolResult | mcp.types.CreateTaskResult:
|
||||
"""
|
||||
Call a tool.
|
||||
|
||||
Iterates through all providers to find the tool.
|
||||
First provider wins.
|
||||
"""
|
||||
tool_name = context.message.name
|
||||
|
||||
for provider in self._providers:
|
||||
tool = await provider.get_tool(tool_name)
|
||||
if tool is not None and self._is_component_enabled(tool):
|
||||
return await self._execute_tool(
|
||||
tool, tool_name, context.message.arguments or {}
|
||||
)
|
||||
|
||||
raise NotFoundError(f"Unknown tool: {tool_name!r}")
|
||||
|
||||
async def _execute_tool(
|
||||
self, tool: Tool, tool_name: str, arguments: dict[str, Any]
|
||||
) -> ToolResult | mcp.types.CreateTaskResult:
|
||||
"""Run a tool with unified error handling.
|
||||
|
||||
Calls tool._run() which handles task routing - checking the task_metadata
|
||||
contextvar and submitting to Docket if appropriate.
|
||||
"""
|
||||
try:
|
||||
return await tool._run(arguments)
|
||||
except FastMCPError:
|
||||
logger.exception(f"Error calling tool {tool_name!r}")
|
||||
raise
|
||||
except (ValidationError, PydanticValidationError):
|
||||
# Validation errors are never masked - they indicate client input issues
|
||||
logger.exception(f"Error validating tool {tool_name!r}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error calling tool {tool_name!r}")
|
||||
if self._mask_error_details:
|
||||
raise ToolError(f"Error calling tool {tool_name!r}") from e
|
||||
raise ToolError(f"Error calling tool {tool_name!r}: {e}") from e
|
||||
# Set contextvars so Prompt._render() can access them
|
||||
task_token = _task_metadata.set(task_meta_dict)
|
||||
key_token = _docket_fn_key.set(Prompt.make_key(name))
|
||||
try:
|
||||
result = await self.render_prompt(name, arguments)
|
||||
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return mcp.types.ServerResult(result)
|
||||
|
||||
# Normal synchronous result
|
||||
return mcp.types.ServerResult(result.to_mcp_prompt_result())
|
||||
finally:
|
||||
_task_metadata.reset(task_token)
|
||||
_docket_fn_key.reset(key_token)
|
||||
|
||||
except DisabledError as e:
|
||||
raise NotFoundError(f"Unknown prompt: {name!r}") from e
|
||||
except NotFoundError as e:
|
||||
raise NotFoundError(f"Unknown prompt: {name!r}") from e
|
||||
|
||||
async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ResourceContent]:
|
||||
"""
|
||||
Handle MCP 'readResource' requests.
|
||||
Handle MCP 'readResource' requests (used by Context.read_resource()).
|
||||
|
||||
Delegates to _read_resource, which should be overridden by FastMCP subclasses.
|
||||
Delegates to read_resource() without task metadata, so CreateTaskResult
|
||||
should never be returned.
|
||||
"""
|
||||
logger.debug(f"[{self.name}] Handler called: read_resource %s", uri)
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=self):
|
||||
try:
|
||||
# Task routing handled by custom handler
|
||||
# Note: Without task metadata, _read_resource_middleware always returns list
|
||||
result = await self._read_resource_middleware(uri)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
# Should never happen without task metadata, but handle for type safety
|
||||
raise RuntimeError(
|
||||
"Unexpected CreateTaskResult in _read_resource_mcp"
|
||||
)
|
||||
return result
|
||||
except DisabledError as e:
|
||||
# convert to NotFoundError to avoid leaking resource presence
|
||||
raise NotFoundError(f"Unknown resource: {str(uri)!r}") from e
|
||||
except NotFoundError as e:
|
||||
# standardize NotFound message
|
||||
raise NotFoundError(f"Unknown resource: {str(uri)!r}") from e
|
||||
|
||||
async def _read_resource_middleware(
|
||||
self,
|
||||
uri: AnyUrl | str,
|
||||
) -> list[ResourceContent] | mcp.types.CreateTaskResult:
|
||||
"""
|
||||
Applies this server's middleware and delegates the filtered call to the manager.
|
||||
|
||||
Returns list[ResourceContent] for synchronous execution, or CreateTaskResult
|
||||
if the resource was submitted to Docket for background execution.
|
||||
"""
|
||||
|
||||
# Convert string URI to AnyUrl if needed
|
||||
uri_param = AnyUrl(uri) if isinstance(uri, str) else uri
|
||||
|
||||
mw_context = MiddlewareContext(
|
||||
message=mcp.types.ReadResourceRequestParams(uri=uri_param),
|
||||
source="client",
|
||||
type="request",
|
||||
method="resources/read",
|
||||
fastmcp_context=fastmcp.server.dependencies.get_context(),
|
||||
)
|
||||
result = await self._apply_middleware(
|
||||
context=mw_context, call_next=self._read_resource
|
||||
)
|
||||
# CreateTaskResult passes through, otherwise convert to list
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
try:
|
||||
result = await self.read_resource(str(uri))
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
# Should never happen without task metadata, but handle for type safety
|
||||
raise RuntimeError("Unexpected CreateTaskResult in _read_resource_mcp")
|
||||
return result
|
||||
return list(result)
|
||||
|
||||
async def _read_resource(
|
||||
self,
|
||||
context: MiddlewareContext[mcp.types.ReadResourceRequestParams],
|
||||
) -> list[ResourceContent] | mcp.types.CreateTaskResult:
|
||||
"""
|
||||
Read a resource.
|
||||
|
||||
Iterates through all providers to find the resource.
|
||||
First provider wins. Checks concrete resources first, then templates.
|
||||
|
||||
Returns list[ResourceContent] for synchronous execution, or CreateTaskResult
|
||||
if the resource was submitted to Docket for background execution.
|
||||
"""
|
||||
uri_str = str(context.message.uri)
|
||||
|
||||
# First pass: try concrete resources from all providers
|
||||
for provider in self._providers:
|
||||
resource = await provider.get_resource(uri_str)
|
||||
if resource is not None and self._is_component_enabled(resource):
|
||||
result = await self._execute_resource(resource, uri_str)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
if result.mime_type is None:
|
||||
result.mime_type = resource.mime_type
|
||||
return [result]
|
||||
|
||||
# Second pass: try templates from all providers
|
||||
for provider in self._providers:
|
||||
template = await provider.get_resource_template(uri_str)
|
||||
if template is not None and self._is_component_enabled(template):
|
||||
params = template.matches(uri_str)
|
||||
if params is not None:
|
||||
result = await self._execute_template(template, uri_str, params)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
return [result]
|
||||
|
||||
raise NotFoundError(f"Unknown resource: {uri_str!r}")
|
||||
|
||||
async def _execute_resource(
|
||||
self, resource: Resource, uri_str: str
|
||||
) -> ResourceContent | mcp.types.CreateTaskResult:
|
||||
"""Read a resource with unified error handling.
|
||||
|
||||
Calls resource._read() which handles task routing - checking the task_metadata
|
||||
contextvar and submitting to Docket if appropriate.
|
||||
"""
|
||||
try:
|
||||
return await resource._read()
|
||||
except (FastMCPError, McpError):
|
||||
logger.exception(f"Error reading resource {uri_str!r}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error reading resource {uri_str!r}")
|
||||
if self._mask_error_details:
|
||||
raise ResourceError(f"Error reading resource {uri_str!r}") from e
|
||||
raise ResourceError(f"Error reading resource {uri_str!r}: {e}") from e
|
||||
|
||||
async def _execute_template(
|
||||
self,
|
||||
template: ResourceTemplate,
|
||||
uri_str: str,
|
||||
params: dict[str, Any],
|
||||
) -> ResourceContent | mcp.types.CreateTaskResult:
|
||||
"""Execute a template with unified error handling.
|
||||
|
||||
Calls template._read() which handles task routing - checking the task_metadata
|
||||
contextvar and submitting to Docket if appropriate.
|
||||
"""
|
||||
try:
|
||||
return await template._read(uri_str, params)
|
||||
except (FastMCPError, McpError):
|
||||
logger.exception(f"Error reading resource {uri_str!r}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error reading resource {uri_str!r}")
|
||||
if self._mask_error_details:
|
||||
raise ResourceError(f"Error reading resource {uri_str!r}") from e
|
||||
raise ResourceError(f"Error reading resource {uri_str!r}: {e}") from e
|
||||
except DisabledError as e:
|
||||
# convert to NotFoundError to avoid leaking resource presence
|
||||
raise NotFoundError(f"Unknown resource: {str(uri)!r}") from e
|
||||
except NotFoundError as e:
|
||||
# standardize NotFound message
|
||||
raise NotFoundError(f"Unknown resource: {str(uri)!r}") from e
|
||||
|
||||
async def _get_prompt_mcp(
|
||||
self, name: str, arguments: dict[str, Any] | None = None
|
||||
) -> GetPromptResult:
|
||||
"""
|
||||
Handle MCP 'getPrompt' requests.
|
||||
Handle MCP 'getPrompt' requests (used by Context.get_prompt()).
|
||||
|
||||
Delegates to _get_prompt, which should be overridden by FastMCP subclasses.
|
||||
Delegates to render_prompt() and converts to MCP SDK type.
|
||||
"""
|
||||
import fastmcp.server.context
|
||||
|
||||
logger.debug(
|
||||
f"[{self.name}] Handler called: get_prompt %s with %s", name, arguments
|
||||
)
|
||||
|
||||
async with fastmcp.server.context.Context(fastmcp=self):
|
||||
try:
|
||||
# Task routing handled by custom handler
|
||||
return await self._get_prompt_middleware(name, arguments)
|
||||
except DisabledError as e:
|
||||
# convert to NotFoundError to avoid leaking prompt presence
|
||||
raise NotFoundError(f"Unknown prompt: {name}") from e
|
||||
except NotFoundError as e:
|
||||
# standardize NotFound message
|
||||
raise NotFoundError(f"Unknown prompt: {name}") from e
|
||||
|
||||
async def _get_prompt_middleware(
|
||||
self, name: str, arguments: dict[str, Any] | None = None
|
||||
) -> GetPromptResult:
|
||||
"""
|
||||
Applies this server's middleware and delegates the filtered call to the manager.
|
||||
Converts PromptResult to GetPromptResult for MCP protocol.
|
||||
|
||||
Note: This method assumes synchronous execution. For task-augmented execution,
|
||||
use _get_prompt_content_middleware directly and handle CreateTaskResult.
|
||||
"""
|
||||
result = await self._get_prompt_content_middleware(name, arguments)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
raise RuntimeError(
|
||||
"Prompt returned CreateTaskResult but _get_prompt_middleware "
|
||||
"expects synchronous execution"
|
||||
)
|
||||
return result.to_mcp_prompt_result()
|
||||
|
||||
async def _get_prompt_content_middleware(
|
||||
self, name: str, arguments: dict[str, Any] | None = None
|
||||
) -> PromptResult | mcp.types.CreateTaskResult:
|
||||
"""
|
||||
Applies this server's middleware and returns PromptResult.
|
||||
Used internally and by parent servers for mounted prompts.
|
||||
|
||||
Returns PromptResult for synchronous execution, or CreateTaskResult
|
||||
if the prompt was submitted to Docket for background execution.
|
||||
"""
|
||||
mw_context = MiddlewareContext(
|
||||
message=mcp.types.GetPromptRequestParams(name=name, arguments=arguments),
|
||||
source="client",
|
||||
type="request",
|
||||
method="prompts/get",
|
||||
fastmcp_context=fastmcp.server.dependencies.get_context(),
|
||||
)
|
||||
return await self._apply_middleware(
|
||||
context=mw_context, call_next=self._get_prompt
|
||||
)
|
||||
|
||||
async def _get_prompt(
|
||||
self,
|
||||
context: MiddlewareContext[mcp.types.GetPromptRequestParams],
|
||||
) -> PromptResult | mcp.types.CreateTaskResult:
|
||||
"""
|
||||
Get a prompt.
|
||||
|
||||
Iterates through all providers to find the prompt.
|
||||
First provider wins.
|
||||
|
||||
Returns PromptResult for synchronous execution, or CreateTaskResult
|
||||
if the prompt was submitted to Docket for background execution.
|
||||
"""
|
||||
name = context.message.name
|
||||
|
||||
for provider in self._providers:
|
||||
prompt = await provider.get_prompt(name)
|
||||
if prompt is not None and self._is_component_enabled(prompt):
|
||||
return await self._execute_prompt(
|
||||
prompt, name, context.message.arguments
|
||||
)
|
||||
|
||||
raise NotFoundError(f"Unknown prompt: {name!r}")
|
||||
|
||||
async def _execute_prompt(
|
||||
self, prompt: Prompt, name: str, arguments: dict[str, Any] | None
|
||||
) -> PromptResult | mcp.types.CreateTaskResult:
|
||||
"""Render a prompt with unified error handling.
|
||||
|
||||
Calls prompt._render() which handles task routing - checking the task_metadata
|
||||
contextvar and submitting to Docket if appropriate.
|
||||
"""
|
||||
try:
|
||||
return await prompt._render(arguments)
|
||||
except (FastMCPError, McpError):
|
||||
logger.exception(f"Error rendering prompt {name!r}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error rendering prompt {name!r}")
|
||||
if self._mask_error_details:
|
||||
raise PromptError(f"Error rendering prompt {name!r}") from e
|
||||
raise PromptError(f"Error rendering prompt {name!r}: {e}") from e
|
||||
result = await self.render_prompt(name, arguments)
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
# Should never happen without task metadata
|
||||
raise RuntimeError("Unexpected CreateTaskResult in _get_prompt_mcp")
|
||||
return result.to_mcp_prompt_result()
|
||||
except DisabledError as e:
|
||||
# convert to NotFoundError to avoid leaking prompt presence
|
||||
raise NotFoundError(f"Unknown prompt: {name}") from e
|
||||
except NotFoundError as e:
|
||||
# standardize NotFound message
|
||||
raise NotFoundError(f"Unknown prompt: {name}") from e
|
||||
|
||||
def add_tool(self, tool: Tool) -> Tool:
|
||||
"""Add a tool to the server.
|
||||
|
|
|
|||
|
|
@ -106,11 +106,11 @@ async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo:
|
|||
Returns:
|
||||
FastMCPInfo dataclass containing the extracted information
|
||||
"""
|
||||
# Get all components via middleware to respect filtering and preserve metadata
|
||||
tools_list = await mcp.get_tools(apply_middleware=True)
|
||||
prompts_list = await mcp.get_prompts(apply_middleware=True)
|
||||
resources_list = await mcp.get_resources(apply_middleware=True)
|
||||
templates_list = await mcp.get_resource_templates(apply_middleware=True)
|
||||
# Get all components directly without middleware (auth, rate limiting, etc.)
|
||||
tools_list = await mcp.get_tools(run_middleware=False)
|
||||
prompts_list = await mcp.get_prompts(run_middleware=False)
|
||||
resources_list = await mcp.get_resources(run_middleware=False)
|
||||
templates_list = await mcp.get_resource_templates(run_middleware=False)
|
||||
|
||||
# Extract detailed tool information
|
||||
tool_infos = []
|
||||
|
|
|
|||
|
|
@ -306,37 +306,37 @@ class TestComponentManagementRoutes:
|
|||
"""Test enabling a non-existent tool returns 404."""
|
||||
response = client.post("/tools/nonexistent_tool/enable")
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.text == "Unknown tool: nonexistent_tool"
|
||||
assert response.text == "Unknown tool: 'nonexistent_tool'"
|
||||
|
||||
def test_disable_nonexistent_tool(self, client):
|
||||
"""Test disabling a non-existent tool returns 404."""
|
||||
response = client.post("/tools/nonexistent_tool/disable")
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.text == "Unknown tool: nonexistent_tool"
|
||||
assert response.text == "Unknown tool: 'nonexistent_tool'"
|
||||
|
||||
def test_enable_nonexistent_resource(self, client):
|
||||
"""Test enabling a non-existent resource returns 404."""
|
||||
response = client.post("/resources/nonexistent://resource/enable")
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.text == "Unknown resource: nonexistent://resource"
|
||||
assert response.text == "Unknown resource: 'nonexistent://resource'"
|
||||
|
||||
def test_disable_nonexistent_resource(self, client):
|
||||
"""Test disabling a non-existent resource returns 404."""
|
||||
response = client.post("/resources/nonexistent://resource/disable")
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.text == "Unknown resource: nonexistent://resource"
|
||||
assert response.text == "Unknown resource: 'nonexistent://resource'"
|
||||
|
||||
def test_enable_nonexistent_prompt(self, client):
|
||||
"""Test enabling a non-existent prompt returns 404."""
|
||||
response = client.post("/prompts/nonexistent_prompt/enable")
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.text == "Unknown prompt: nonexistent_prompt"
|
||||
assert response.text == "Unknown prompt: 'nonexistent_prompt'"
|
||||
|
||||
def test_disable_nonexistent_prompt(self, client):
|
||||
"""Test disabling a non-existent prompt returns 404."""
|
||||
response = client.post("/prompts/nonexistent_prompt/disable")
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.text == "Unknown prompt: nonexistent_prompt"
|
||||
assert response.text == "Unknown prompt: 'nonexistent_prompt'"
|
||||
|
||||
|
||||
class TestAuthComponentManagementRoutes:
|
||||
|
|
|
|||
|
|
@ -464,6 +464,160 @@ class TestMiddlewareHooks:
|
|||
assert result.structured_content["result"] == 108
|
||||
|
||||
|
||||
class TestApplyMiddlewareParameter:
|
||||
"""Tests for run_middleware parameter on execution methods."""
|
||||
|
||||
async def test_call_tool_with_run_middleware_true(self):
|
||||
"""Middleware is applied when run_middleware=True (default)."""
|
||||
recording = RecordingMiddleware()
|
||||
server = FastMCP()
|
||||
|
||||
@server.tool
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
server.add_middleware(recording)
|
||||
|
||||
result = await server.call_tool("add", {"a": 1, "b": 2})
|
||||
|
||||
assert result.structured_content["result"] == 3 # type: ignore[union-attr,index]
|
||||
assert recording.assert_called(hook="on_call_tool", times=1)
|
||||
|
||||
async def test_call_tool_with_run_middleware_false(self):
|
||||
"""Middleware is NOT applied when run_middleware=False."""
|
||||
recording = RecordingMiddleware()
|
||||
server = FastMCP()
|
||||
|
||||
@server.tool
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
server.add_middleware(recording)
|
||||
|
||||
result = await server.call_tool("add", {"a": 1, "b": 2}, run_middleware=False)
|
||||
|
||||
assert result.structured_content["result"] == 3 # type: ignore[union-attr,index]
|
||||
# Middleware should not have been called
|
||||
assert len(recording.calls) == 0
|
||||
|
||||
async def test_read_resource_with_run_middleware_true(self):
|
||||
"""Middleware is applied when run_middleware=True (default)."""
|
||||
recording = RecordingMiddleware()
|
||||
server = FastMCP()
|
||||
|
||||
@server.resource("resource://test")
|
||||
def test_resource() -> str:
|
||||
return "test content"
|
||||
|
||||
server.add_middleware(recording)
|
||||
|
||||
result = await server.read_resource("resource://test")
|
||||
|
||||
assert len(result) == 1 # type: ignore[arg-type]
|
||||
assert result[0].content == "test content" # type: ignore[union-attr,index]
|
||||
assert recording.assert_called(hook="on_read_resource", times=1)
|
||||
|
||||
async def test_read_resource_with_run_middleware_false(self):
|
||||
"""Middleware is NOT applied when run_middleware=False."""
|
||||
recording = RecordingMiddleware()
|
||||
server = FastMCP()
|
||||
|
||||
@server.resource("resource://test")
|
||||
def test_resource() -> str:
|
||||
return "test content"
|
||||
|
||||
server.add_middleware(recording)
|
||||
|
||||
result = await server.read_resource("resource://test", run_middleware=False)
|
||||
|
||||
assert len(result) == 1 # type: ignore[arg-type]
|
||||
assert result[0].content == "test content" # type: ignore[union-attr,index]
|
||||
# Middleware should not have been called
|
||||
assert len(recording.calls) == 0
|
||||
|
||||
async def test_read_resource_template_with_run_middleware_false(self):
|
||||
"""Templates also skip middleware when run_middleware=False."""
|
||||
recording = RecordingMiddleware()
|
||||
server = FastMCP()
|
||||
|
||||
@server.resource("resource://items/{item_id}")
|
||||
def get_item(item_id: int) -> str:
|
||||
return f"item {item_id}"
|
||||
|
||||
server.add_middleware(recording)
|
||||
|
||||
result = await server.read_resource("resource://items/42", run_middleware=False)
|
||||
|
||||
assert len(result) == 1 # type: ignore[arg-type]
|
||||
assert result[0].content == "item 42" # type: ignore[union-attr,index]
|
||||
assert len(recording.calls) == 0
|
||||
|
||||
async def test_render_prompt_with_run_middleware_true(self):
|
||||
"""Middleware is applied when run_middleware=True (default)."""
|
||||
recording = RecordingMiddleware()
|
||||
server = FastMCP()
|
||||
|
||||
@server.prompt
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
server.add_middleware(recording)
|
||||
|
||||
result = await server.render_prompt("greet", {"name": "World"})
|
||||
|
||||
assert len(result.messages) == 1 # type: ignore[union-attr]
|
||||
assert result.messages[0].content.text == "Hello, World!" # type: ignore[union-attr]
|
||||
assert recording.assert_called(hook="on_get_prompt", times=1)
|
||||
|
||||
async def test_render_prompt_with_run_middleware_false(self):
|
||||
"""Middleware is NOT applied when run_middleware=False."""
|
||||
recording = RecordingMiddleware()
|
||||
server = FastMCP()
|
||||
|
||||
@server.prompt
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
server.add_middleware(recording)
|
||||
|
||||
result = await server.render_prompt(
|
||||
"greet", {"name": "World"}, run_middleware=False
|
||||
)
|
||||
|
||||
assert len(result.messages) == 1 # type: ignore[union-attr]
|
||||
assert result.messages[0].content.text == "Hello, World!" # type: ignore[union-attr]
|
||||
# Middleware should not have been called
|
||||
assert len(recording.calls) == 0
|
||||
|
||||
async def test_middleware_modification_skipped_when_run_middleware_false(self):
|
||||
"""Middleware that modifies args/results is skipped."""
|
||||
|
||||
class ModifyingMiddleware(Middleware):
|
||||
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
||||
# Double the 'a' argument
|
||||
assert context.message.arguments is not None
|
||||
context.message.arguments["a"] *= 2
|
||||
return await call_next(context)
|
||||
|
||||
server = FastMCP()
|
||||
|
||||
@server.tool
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
server.add_middleware(ModifyingMiddleware())
|
||||
|
||||
# With middleware: a=5 becomes a=10, result = 10 + 3 = 13
|
||||
result_with = await server.call_tool("add", {"a": 5, "b": 3})
|
||||
assert result_with.structured_content["result"] == 13 # type: ignore[union-attr,index]
|
||||
|
||||
# Without middleware: a=5 stays a=5, result = 5 + 3 = 8
|
||||
result_without = await server.call_tool(
|
||||
"add", {"a": 5, "b": 3}, run_middleware=False
|
||||
)
|
||||
assert result_without.structured_content["result"] == 8 # type: ignore[union-attr,index]
|
||||
|
||||
|
||||
class TestNestedMiddlewareHooks:
|
||||
@pytest.fixture
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -1165,7 +1165,7 @@ class TestToolDecorator:
|
|||
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
|
||||
with pytest.raises(NotFoundError, match="Unknown tool: add"):
|
||||
with pytest.raises(NotFoundError, match="Unknown tool: 'add'"):
|
||||
await mcp._call_tool_mcp("add", {"x": 1, "y": 2})
|
||||
|
||||
async def test_tool_decorator(self):
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ class TestTools:
|
|||
mcp_tools = await mcp.get_tools()
|
||||
assert not any(t.name == "adder" for t in mcp_tools)
|
||||
|
||||
with pytest.raises(NotFoundError, match="Unknown tool: adder"):
|
||||
with pytest.raises(NotFoundError, match="Unknown tool: 'adder'"):
|
||||
await mcp._call_tool_mcp("adder", {"a": 1, "b": 2})
|
||||
|
||||
async def test_add_tool_at_init(self):
|
||||
|
|
|
|||
|
|
@ -29,14 +29,14 @@ async def test_transformed_tool_filtering():
|
|||
"""Echo back the message provided."""
|
||||
return message
|
||||
|
||||
tools = await mcp.get_tools(apply_middleware=True)
|
||||
tools = await mcp.get_tools(run_middleware=True)
|
||||
assert len(tools) == 0
|
||||
|
||||
mcp.add_tool_transformation(
|
||||
"echo", ToolTransformConfig(name="echo_transformed", tags={"enabled_tools"})
|
||||
)
|
||||
|
||||
tools = await mcp.get_tools(apply_middleware=True)
|
||||
tools = await mcp.get_tools(run_middleware=True)
|
||||
assert len(tools) == 1
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue