From 1977e11d54c4173809dfc186941a1d27d793111f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 25 Dec 2025 10:00:22 -0500 Subject: [PATCH 1/4] Consolidate execution method chains into single public API Add call_tool(), read_resource(), render_prompt() methods with apply_middleware parameter, following the pattern from #2719. --- .../server/providers/fastmcp_provider.py | 91 +-- src/fastmcp/server/server.py | 680 ++++++++---------- tests/server/middleware/test_middleware.py | 156 ++++ 3 files changed, 491 insertions(+), 436 deletions(-) diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index 823bcbb975..b3c206b660 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -97,20 +97,19 @@ async def _run( 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 @@ async def _read(self) -> ResourceContent | mcp.types.CreateTaskResult: 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 @@ async def read(self) -> ResourceContent | mcp.types.CreateTaskResult: # type: i 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 @@ async def _render( 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 @@ async def render( 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 @@ async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: 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 @@ async def _read( 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 @@ async def _read( 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) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 05b7023c95..c300077586 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1142,6 +1142,233 @@ async def get_component( raise NotFoundError(f"Unknown component: {key}") + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + *, + apply_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) + apply_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 apply_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._apply_middleware( + context=mw_context, + call_next=lambda context: self.call_tool( + context.message.name, + context.message.arguments or {}, + apply_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, + *, + apply_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 + apply_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 apply_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._apply_middleware( + context=mw_context, + call_next=lambda context: self.read_resource( + str(context.message.uri), + apply_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, + *, + apply_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) + apply_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 apply_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._apply_middleware( + context=mw_context, + call_next=lambda context: self.render_prompt( + context.message.name, + context.message.arguments, + apply_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, @@ -1282,7 +1509,7 @@ async def _call_tool_mcp( """ 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 +1525,34 @@ async def _call_tool_mcp( 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 - - # 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) + 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: + 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}") from e + except NotFoundError as e: + raise NotFoundError(f"Unknown tool: {key}") from e async def _read_resource_handler( self, req: mcp.types.ReadResourceRequest @@ -1352,34 +1576,29 @@ async def _read_resource_handler( 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 could be CreateTaskResult (from nested Resource._read()) - 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) + result = await self.read_resource(str(uri)) + + 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) - 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 +1623,73 @@ async def _get_prompt_handler( 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: - # 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 + result = await self.render_prompt(name, arguments) - 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 {} - ) + if isinstance(result, mcp.types.CreateTaskResult): + return mcp.types.ServerResult(result) - raise NotFoundError(f"Unknown tool: {tool_name!r}") + # Normal synchronous result + return mcp.types.ServerResult(result.to_mcp_prompt_result()) + finally: + _task_metadata.reset(task_token) + _docket_fn_key.reset(key_token) - 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 + 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): - 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 + 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 + 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. diff --git a/tests/server/middleware/test_middleware.py b/tests/server/middleware/test_middleware.py index c21291befa..2ae6802665 100644 --- a/tests/server/middleware/test_middleware.py +++ b/tests/server/middleware/test_middleware.py @@ -464,6 +464,162 @@ async def on_call_tool( assert result.structured_content["result"] == 108 +class TestApplyMiddlewareParameter: + """Tests for apply_middleware parameter on execution methods.""" + + async def test_call_tool_with_apply_middleware_true(self): + """Middleware is applied when apply_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_apply_middleware_false(self): + """Middleware is NOT applied when apply_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}, apply_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_apply_middleware_true(self): + """Middleware is applied when apply_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_apply_middleware_false(self): + """Middleware is NOT applied when apply_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", apply_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_apply_middleware_false(self): + """Templates also skip middleware when apply_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", apply_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_apply_middleware_true(self): + """Middleware is applied when apply_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_apply_middleware_false(self): + """Middleware is NOT applied when apply_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"}, apply_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_apply_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}, apply_middleware=False + ) + assert result_without.structured_content["result"] == 8 # type: ignore[union-attr,index] + + class TestNestedMiddlewareHooks: @pytest.fixture @staticmethod From d30a4c5ff7596f365b0f9bd4e9562bf347db5d1e Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 25 Dec 2025 10:12:46 -0500 Subject: [PATCH 2/4] Rename apply_middleware to run_middleware Also fix inspect.py to skip middleware during introspection. --- src/fastmcp/server/low_level.py | 2 +- .../server/providers/fastmcp_provider.py | 8 +- src/fastmcp/server/server.py | 82 +++++++++---------- src/fastmcp/utilities/inspect.py | 10 +-- tests/server/middleware/test_middleware.py | 44 +++++----- tests/server/test_tool_transformation.py | 4 +- 6 files changed, 73 insertions(+), 77 deletions(-) diff --git a/src/fastmcp/server/low_level.py b/src/fastmcp/server/low_level.py index 536b6aa1f4..2db78e543c 100644 --- a/src/fastmcp/server/low_level.py +++ b/src/fastmcp/server/low_level.py @@ -106,7 +106,7 @@ async def call_original_handler( ) try: - return await self.fastmcp._apply_middleware( + return await self.fastmcp._run_middleware( mw_context, call_original_handler ) except McpError as e: diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index b3c206b660..cf6b3f6f39 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -426,7 +426,7 @@ async def list_tools(self) -> Sequence[Tool]: 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: @@ -445,7 +445,7 @@ async def list_resources(self) -> Sequence[Resource]: 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: @@ -463,7 +463,7 @@ async def list_resource_templates(self) -> Sequence[ResourceTemplate]: 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 ] @@ -486,7 +486,7 @@ async def list_prompts(self) -> Sequence[Prompt]: 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: diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index c300077586..e6d22609d4 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -692,7 +692,7 @@ async def handle_cancel_task(req: CancelTaskRequest) -> ServerResult: 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 @@ def _is_component_enabled(self, component: FastMCPComponent) -> bool: """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 @@ async def get_tools(self, *, apply_middleware: bool = False) -> list[Tool]: 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), ) ) @@ -889,17 +887,17 @@ async def _get_resource_or_template_or_none( 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 @@ async def get_resources(self, *, apply_middleware: bool = False) -> list[Resourc 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 @@ async def get_resource(self, uri: str) -> Resource: 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 @@ async def get_resource_templates( 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 @@ async def get_resource_templates( 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 @@ async def get_resource_template(self, uri: str) -> ResourceTemplate: 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 @@ async def get_prompts(self, *, apply_middleware: bool = False) -> list[Prompt]: 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 ), ) ) @@ -1147,7 +1145,7 @@ async def call_tool( name: str, arguments: dict[str, Any] | None = None, *, - apply_middleware: bool = True, + run_middleware: bool = True, ) -> ToolResult | mcp.types.CreateTaskResult: """Call a tool by name. @@ -1156,7 +1154,7 @@ async def call_tool( Args: name: The tool name arguments: Tool arguments (optional) - apply_middleware: If True (default), apply the middleware chain. + run_middleware: If True (default), apply the middleware chain. Set to False when called from middleware to avoid re-applying. Returns: @@ -1169,7 +1167,7 @@ async def call_tool( ValidationError: If arguments fail validation """ async with fastmcp.server.context.Context(fastmcp=self) as ctx: - if apply_middleware: + if run_middleware: mw_context = MiddlewareContext[CallToolRequestParams]( message=mcp.types.CallToolRequestParams( name=name, arguments=arguments or {} @@ -1179,12 +1177,12 @@ async def call_tool( method="tools/call", fastmcp_context=ctx, ) - return await self._apply_middleware( + return await self._run_middleware( context=mw_context, call_next=lambda context: self.call_tool( context.message.name, context.message.arguments or {}, - apply_middleware=False, + run_middleware=False, ), ) @@ -1212,7 +1210,7 @@ async def read_resource( self, uri: str, *, - apply_middleware: bool = True, + run_middleware: bool = True, ) -> list[ResourceContent] | mcp.types.CreateTaskResult: """Read a resource by URI. @@ -1221,7 +1219,7 @@ async def read_resource( Args: uri: The resource URI - apply_middleware: If True (default), apply the middleware chain. + run_middleware: If True (default), apply the middleware chain. Set to False when called from middleware to avoid re-applying. Returns: @@ -1233,7 +1231,7 @@ async def read_resource( ResourceError: If resource read fails """ async with fastmcp.server.context.Context(fastmcp=self) as ctx: - if apply_middleware: + if run_middleware: uri_param = AnyUrl(uri) mw_context = MiddlewareContext( message=mcp.types.ReadResourceRequestParams(uri=uri_param), @@ -1242,11 +1240,11 @@ async def read_resource( method="resources/read", fastmcp_context=ctx, ) - result = await self._apply_middleware( + result = await self._run_middleware( context=mw_context, call_next=lambda context: self.read_resource( str(context.message.uri), - apply_middleware=False, + run_middleware=False, ), ) if isinstance(result, mcp.types.CreateTaskResult): @@ -1309,7 +1307,7 @@ async def render_prompt( name: str, arguments: dict[str, Any] | None = None, *, - apply_middleware: bool = True, + run_middleware: bool = True, ) -> PromptResult | mcp.types.CreateTaskResult: """Render a prompt by name. @@ -1319,7 +1317,7 @@ async def render_prompt( Args: name: The prompt name arguments: Prompt arguments (optional) - apply_middleware: If True (default), apply the middleware chain. + run_middleware: If True (default), apply the middleware chain. Set to False when called from middleware to avoid re-applying. Returns: @@ -1331,7 +1329,7 @@ async def render_prompt( PromptError: If prompt rendering fails """ async with fastmcp.server.context.Context(fastmcp=self) as ctx: - if apply_middleware: + if run_middleware: mw_context = MiddlewareContext( message=mcp.types.GetPromptRequestParams( name=name, arguments=arguments @@ -1341,12 +1339,12 @@ async def render_prompt( method="prompts/get", fastmcp_context=ctx, ) - return await self._apply_middleware( + return await self._run_middleware( context=mw_context, call_next=lambda context: self.render_prompt( context.message.name, context.message.arguments, - apply_middleware=False, + run_middleware=False, ), ) @@ -1438,7 +1436,7 @@ async def _list_tools_mcp(self) -> list[SDKTool]: 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, @@ -1455,7 +1453,7 @@ async def _list_resources_mcp(self) -> list[SDKResource]: 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), @@ -1472,7 +1470,7 @@ async def _list_resource_templates_mcp(self) -> list[SDKResourceTemplate]: 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, @@ -1489,7 +1487,7 @@ async def _list_prompts_mcp(self) -> list[SDKPrompt]: 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, diff --git a/src/fastmcp/utilities/inspect.py b/src/fastmcp/utilities/inspect.py index 99e8ddd85c..38002799f7 100644 --- a/src/fastmcp/utilities/inspect.py +++ b/src/fastmcp/utilities/inspect.py @@ -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 = [] diff --git a/tests/server/middleware/test_middleware.py b/tests/server/middleware/test_middleware.py index 2ae6802665..8a6fbf0c91 100644 --- a/tests/server/middleware/test_middleware.py +++ b/tests/server/middleware/test_middleware.py @@ -465,10 +465,10 @@ async def on_call_tool( class TestApplyMiddlewareParameter: - """Tests for apply_middleware parameter on execution methods.""" + """Tests for run_middleware parameter on execution methods.""" - async def test_call_tool_with_apply_middleware_true(self): - """Middleware is applied when apply_middleware=True (default).""" + async def test_call_tool_with_run_middleware_true(self): + """Middleware is applied when run_middleware=True (default).""" recording = RecordingMiddleware() server = FastMCP() @@ -483,8 +483,8 @@ def add(a: int, b: int) -> int: 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_apply_middleware_false(self): - """Middleware is NOT applied when apply_middleware=False.""" + async def test_call_tool_with_run_middleware_false(self): + """Middleware is NOT applied when run_middleware=False.""" recording = RecordingMiddleware() server = FastMCP() @@ -494,14 +494,14 @@ def add(a: int, b: int) -> int: server.add_middleware(recording) - result = await server.call_tool("add", {"a": 1, "b": 2}, apply_middleware=False) + 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_apply_middleware_true(self): - """Middleware is applied when apply_middleware=True (default).""" + async def test_read_resource_with_run_middleware_true(self): + """Middleware is applied when run_middleware=True (default).""" recording = RecordingMiddleware() server = FastMCP() @@ -517,8 +517,8 @@ def test_resource() -> str: 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_apply_middleware_false(self): - """Middleware is NOT applied when apply_middleware=False.""" + async def test_read_resource_with_run_middleware_false(self): + """Middleware is NOT applied when run_middleware=False.""" recording = RecordingMiddleware() server = FastMCP() @@ -528,15 +528,15 @@ def test_resource() -> str: server.add_middleware(recording) - result = await server.read_resource("resource://test", apply_middleware=False) + 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_apply_middleware_false(self): - """Templates also skip middleware when apply_middleware=False.""" + async def test_read_resource_template_with_run_middleware_false(self): + """Templates also skip middleware when run_middleware=False.""" recording = RecordingMiddleware() server = FastMCP() @@ -546,16 +546,14 @@ def get_item(item_id: int) -> str: server.add_middleware(recording) - result = await server.read_resource( - "resource://items/42", apply_middleware=False - ) + 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_apply_middleware_true(self): - """Middleware is applied when apply_middleware=True (default).""" + async def test_render_prompt_with_run_middleware_true(self): + """Middleware is applied when run_middleware=True (default).""" recording = RecordingMiddleware() server = FastMCP() @@ -571,8 +569,8 @@ def greet(name: str) -> str: 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_apply_middleware_false(self): - """Middleware is NOT applied when apply_middleware=False.""" + async def test_render_prompt_with_run_middleware_false(self): + """Middleware is NOT applied when run_middleware=False.""" recording = RecordingMiddleware() server = FastMCP() @@ -583,7 +581,7 @@ def greet(name: str) -> str: server.add_middleware(recording) result = await server.render_prompt( - "greet", {"name": "World"}, apply_middleware=False + "greet", {"name": "World"}, run_middleware=False ) assert len(result.messages) == 1 # type: ignore[union-attr] @@ -591,7 +589,7 @@ def greet(name: str) -> str: # Middleware should not have been called assert len(recording.calls) == 0 - async def test_middleware_modification_skipped_when_apply_middleware_false(self): + async def test_middleware_modification_skipped_when_run_middleware_false(self): """Middleware that modifies args/results is skipped.""" class ModifyingMiddleware(Middleware): @@ -615,7 +613,7 @@ def add(a: int, b: int) -> int: # Without middleware: a=5 stays a=5, result = 5 + 3 = 8 result_without = await server.call_tool( - "add", {"a": 5, "b": 3}, apply_middleware=False + "add", {"a": 5, "b": 3}, run_middleware=False ) assert result_without.structured_content["result"] == 8 # type: ignore[union-attr,index] diff --git a/tests/server/test_tool_transformation.py b/tests/server/test_tool_transformation.py index 8596f20ce8..fdb1aca9b3 100644 --- a/tests/server/test_tool_transformation.py +++ b/tests/server/test_tool_transformation.py @@ -29,14 +29,14 @@ def echo(message: str) -> str: """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 From b4a2753cb3de516678d1d6efb7a5644c9dc8cd1d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 25 Dec 2025 10:17:16 -0500 Subject: [PATCH 3/4] Fix inconsistent error message formatting --- src/fastmcp/server/server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index e6d22609d4..16cd05c168 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -855,7 +855,7 @@ async def get_tool(self, name: str) -> Tool: 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 @@ -1548,9 +1548,9 @@ async def _call_tool_mcp( _docket_fn_key.reset(key_token) except DisabledError as e: - raise NotFoundError(f"Unknown tool: {key}") from e + raise NotFoundError(f"Unknown tool: {key!r}") from e except NotFoundError as e: - raise NotFoundError(f"Unknown tool: {key}") from e + raise NotFoundError(f"Unknown tool: {key!r}") from e async def _read_resource_handler( self, req: mcp.types.ReadResourceRequest From 20c89312a50ce2f3948124711c7ab653411fe3b4 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 25 Dec 2025 10:28:45 -0500 Subject: [PATCH 4/4] Fix test expectations for !r error formatting --- .../contrib/component_manager/component_manager.py | 2 +- .../contrib/component_manager/component_service.py | 8 ++++---- tests/contrib/test_component_manager.py | 12 ++++++------ tests/server/providers/test_local_provider_tools.py | 2 +- tests/server/test_server.py | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/fastmcp/contrib/component_manager/component_manager.py b/src/fastmcp/contrib/component_manager/component_manager.py index e0de23a8c1..f56b497e5e 100644 --- a/src/fastmcp/contrib/component_manager/component_manager.py +++ b/src/fastmcp/contrib/component_manager/component_manager.py @@ -100,7 +100,7 @@ async def endpoint(request: Request): except NotFoundError as e: raise StarletteHTTPException( status_code=404, - detail=f"Unknown {component}: {name}", + detail=f"Unknown {component}: {name!r}", ) from e return endpoint diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py index c0453f0eaa..dbd385a81e 100644 --- a/src/fastmcp/contrib/component_manager/component_service.py +++ b/src/fastmcp/contrib/component_manager/component_service.py @@ -74,7 +74,7 @@ async def _enable_tool(self, name: str) -> Tool: 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 @@ async def _enable_tool(self, name: str) -> Tool: 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 @@ async def _disable_tool(self, name: str) -> Tool: 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 @@ async def _disable_tool(self, name: str) -> Tool: 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. diff --git a/tests/contrib/test_component_manager.py b/tests/contrib/test_component_manager.py index a56cc13f17..a7e8f4eb86 100644 --- a/tests/contrib/test_component_manager.py +++ b/tests/contrib/test_component_manager.py @@ -306,37 +306,37 @@ def test_enable_nonexistent_tool(self, client): """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: diff --git a/tests/server/providers/test_local_provider_tools.py b/tests/server/providers/test_local_provider_tools.py index 11d8aa791e..edb6e7e974 100644 --- a/tests/server/providers/test_local_provider_tools.py +++ b/tests/server/providers/test_local_provider_tools.py @@ -1165,7 +1165,7 @@ async def test_no_tools_before_decorator(self): 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): diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 214e54f3ce..772e1599b7 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -91,7 +91,7 @@ def add(a: int, b: int) -> int: 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):