Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ dependencies = [
"mcp>=1.24.0",
"openapi-pydantic>=0.5.1",
"platformdirs>=4.0.0",
"pydocket>=0.15.5",
"pydocket>=0.16.0",
"rich>=13.9.4",
"cyclopts>=4.0.0",
"authlib>=1.6.5",
Expand Down
7 changes: 3 additions & 4 deletions src/fastmcp/prompts/prompt_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from mcp import GetPromptResult

from fastmcp import settings
from fastmcp.exceptions import NotFoundError, PromptError
from fastmcp.exceptions import FastMCPError, NotFoundError, PromptError
from fastmcp.prompts.prompt import FunctionPrompt, Prompt, PromptResult
from fastmcp.settings import DuplicateBehavior
from fastmcp.utilities.logging import get_logger
Expand Down Expand Up @@ -107,9 +107,8 @@ async def render_prompt(
try:
messages = await prompt.render(arguments)
return GetPromptResult(description=prompt.description, messages=messages)
except PromptError as e:
logger.exception(f"Error rendering prompt {name!r}")
raise e
except FastMCPError:
raise
except Exception as e:
logger.exception(f"Error rendering prompt {name!r}")
if self.mask_error_details:
Expand Down
23 changes: 9 additions & 14 deletions src/fastmcp/resources/resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pydantic import AnyUrl

from fastmcp import settings
from fastmcp.exceptions import NotFoundError, ResourceError
from fastmcp.exceptions import FastMCPError, NotFoundError, ResourceError
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import (
ResourceTemplate,
Expand Down Expand Up @@ -268,10 +268,9 @@ async def get_resource(self, uri: AnyUrl | str) -> Resource:
uri_str,
params=params,
)
# Pass through ResourceErrors as-is
except ResourceError as e:
logger.error(f"Error creating resource from template: {e}")
raise e
# Pass through FastMCPErrors as-is
except FastMCPError:
raise
# Handle other exceptions
except Exception as e:
logger.error(f"Error creating resource from template: {e}")
Expand Down Expand Up @@ -299,10 +298,9 @@ async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
try:
return await resource.read()

# raise ResourceErrors as-is
except ResourceError as e:
logger.exception(f"Error reading resource {uri_str!r}")
raise e
# raise FastMCPErrors as-is
except FastMCPError:
raise

# Handle other exceptions
except Exception as e:
Expand All @@ -322,11 +320,8 @@ async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
try:
resource = await template.create_resource(uri_str, params=params)
return await resource.read()
except ResourceError as e:
logger.exception(
f"Error reading resource from template {uri_str!r}"
)
raise e
except FastMCPError:
raise
except Exception as e:
logger.exception(
f"Error reading resource from template {uri_str!r}"
Expand Down
5 changes: 5 additions & 0 deletions src/fastmcp/server/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from mcp.server.lowlevel.server import request_ctx
from starlette.requests import Request

from fastmcp.exceptions import FastMCPError
from fastmcp.server.auth import AccessToken
from fastmcp.server.http import _current_http_request
from fastmcp.utilities.types import is_class_member_of_type
Expand Down Expand Up @@ -188,6 +189,10 @@ async def _resolve_fastmcp_dependencies(
resolved[parameter] = await stack.enter_async_context(
dependency
)
except FastMCPError:
# Let FastMCPError subclasses (ToolError, ResourceError, etc.)
# propagate unchanged so they can be handled appropriately
raise
except Exception as error:
fn_name = getattr(fn, "__name__", repr(fn))
raise RuntimeError(
Expand Down
2 changes: 1 addition & 1 deletion src/fastmcp/server/middleware/error_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def _transform_error(self, error: Exception) -> Exception:
return error

# Map common exceptions to appropriate MCP error codes
error_type = type(error)
error_type = type(error.__cause__) if error.__cause__ else type(error)

if error_type in (ValueError, TypeError):
return McpError(
Expand Down
43 changes: 13 additions & 30 deletions src/fastmcp/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,23 +105,6 @@
logger = get_logger(__name__)


def _create_named_fn_wrapper(fn: Callable[..., Any], name: str) -> Callable[..., Any]:
"""Create a wrapper function with a custom __name__ for Docket registration.

Docket uses fn.__name__ as the key for function registration and lookup.
When mounting servers, we need unique names to avoid collisions between
mounted servers that have identically-named functions.
"""
import functools

@functools.wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
return await fn(*args, **kwargs)

wrapper.__name__ = name
return wrapper


DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
Transport = Literal["stdio", "http", "sse", "streamable-http"]

Expand Down Expand Up @@ -437,29 +420,32 @@ async def _docket_lifespan(self) -> AsyncIterator[None]:
isinstance(tool, FunctionTool)
and tool.task_config.mode != "forbidden"
):
docket.register(tool.fn)
docket.register(tool.fn, names=[tool.key])

for prompt in self._prompt_manager._prompts.values():
if (
isinstance(prompt, FunctionPrompt)
and prompt.task_config.mode != "forbidden"
):
# task execution requires async fn (validated at creation time)
docket.register(cast(Callable[..., Awaitable[Any]], prompt.fn))
docket.register(
cast(Callable[..., Awaitable[Any]], prompt.fn),
names=[prompt.key],
)

for resource in self._resource_manager._resources.values():
if (
isinstance(resource, FunctionResource)
and resource.task_config.mode != "forbidden"
):
docket.register(resource.fn)
docket.register(resource.fn, names=[resource.name])

for template in self._resource_manager._templates.values():
if (
isinstance(template, FunctionResourceTemplate)
and template.task_config.mode != "forbidden"
):
docket.register(template.fn)
docket.register(template.fn, names=[template.name])

# Also register functions from mounted servers so tasks can
# execute in the parent's Docket context
Expand Down Expand Up @@ -535,8 +521,7 @@ async def _register_mounted_server_functions(
fn_name = f"{prefix}_{tool.key}"
else:
fn_name = tool.key
named_fn = _create_named_fn_wrapper(tool.fn, fn_name)
docket.register(named_fn)
docket.register(tool.fn, names=[fn_name])

# Register prompts with prefixed names
for prompt in server._prompt_manager._prompts.values():
Expand All @@ -545,10 +530,10 @@ async def _register_mounted_server_functions(
and prompt.task_config.mode != "forbidden"
):
fn_name = f"{prefix}_{prompt.key}" if prefix else prompt.key
named_fn = _create_named_fn_wrapper(
cast(Callable[..., Awaitable[Any]], prompt.fn), fn_name
docket.register(
cast(Callable[..., Awaitable[Any]], prompt.fn),
names=[fn_name],
)
docket.register(named_fn)

# Register resources with prefixed names (use name, not key/URI)
for resource in server._resource_manager._resources.values():
Expand All @@ -557,8 +542,7 @@ async def _register_mounted_server_functions(
and resource.task_config.mode != "forbidden"
):
fn_name = f"{prefix}_{resource.name}" if prefix else resource.name
named_fn = _create_named_fn_wrapper(resource.fn, fn_name)
docket.register(named_fn)
docket.register(resource.fn, names=[fn_name])

# Register resource templates with prefixed names (use name, not key/URI)
for template in server._resource_manager._templates.values():
Expand All @@ -567,8 +551,7 @@ async def _register_mounted_server_functions(
and template.task_config.mode != "forbidden"
):
fn_name = f"{prefix}_{template.name}" if prefix else template.name
named_fn = _create_named_fn_wrapper(template.fn, fn_name)
docket.register(named_fn)
docket.register(template.fn, names=[fn_name])

# Recursively register from nested mounted servers with accumulated prefix
for nested in server._mounted_servers:
Expand Down
12 changes: 5 additions & 7 deletions src/fastmcp/tools/tool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pydantic import ValidationError

from fastmcp import settings
from fastmcp.exceptions import NotFoundError, ToolError
from fastmcp.exceptions import FastMCPError, NotFoundError, ToolError
from fastmcp.settings import DuplicateBehavior
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.tools.tool_transform import (
Expand Down Expand Up @@ -158,12 +158,10 @@ async def call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult:
tool = await self.get_tool(key)
try:
return await tool.run(arguments)
except ValidationError as e:
logger.exception(f"Error validating tool {key!r}: {e}")
raise e
except ToolError as e:
logger.exception(f"Error calling tool {key!r}")
raise e
except FastMCPError:
raise
except ValidationError:
raise
except Exception as e:
logger.exception(f"Error calling tool {key!r}")
if self.mask_error_details:
Expand Down
11 changes: 5 additions & 6 deletions tests/client/test_sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,17 +117,16 @@ async def nested_sse_server():
ws="websockets-sansio",
)

server_task = asyncio.create_task(uvicorn.Server(config).serve())
uvicorn_server = uvicorn.Server(config)
server_task = asyncio.create_task(uvicorn_server.serve())
await asyncio.sleep(0.1)

try:
yield f"http://127.0.0.1:{port}/nest-outer/nest-inner/mcp/sse/"
finally:
server_task.cancel()
try:
await server_task
except asyncio.CancelledError:
pass
# Graceful shutdown - required for uvicorn 0.39+ due to context isolation
uvicorn_server.should_exit = True
await server_task


async def test_run_server_on_path(sse_server_custom_path: str):
Expand Down
12 changes: 5 additions & 7 deletions tests/client/test_streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,17 +127,15 @@ async def nested_server():
)

# Use the simple asyncio pattern
server_task = asyncio.create_task(uvicorn.Server(config).serve())
uvicorn_server = uvicorn.Server(config)
server_task = asyncio.create_task(uvicorn_server.serve())
await asyncio.sleep(0.1)

yield f"http://127.0.0.1:{port}/nest-outer/nest-inner/final/mcp"

# Cleanup
server_task.cancel()
try:
await server_task
except asyncio.CancelledError:
pass
# Graceful shutdown - required for uvicorn 0.39+ due to context isolation
uvicorn_server.should_exit = True
await server_task


async def test_ping(streamable_http_server: str):
Expand Down
19 changes: 18 additions & 1 deletion tests/server/middleware/test_error_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import pytest
from mcp import McpError

from fastmcp.exceptions import NotFoundError
from fastmcp.exceptions import NotFoundError, ToolError
from fastmcp.server.middleware.error_handling import (
ErrorHandlingMiddleware,
RetryMiddleware,
Expand Down Expand Up @@ -215,6 +215,23 @@ async def test_on_message_error_transform(self, mock_context, caplog):
assert "Invalid params: test error" in exc_info.value.error.message
assert "Error in test_method: ValueError: test error" in caplog.text

async def test_on_message_error_transform_tool_error(self, mock_context, caplog):
"""Test error handling with transformation and cause type."""
middleware = ErrorHandlingMiddleware()
tool_error = ToolError("test error")
tool_error.__cause__ = ValueError()
mock_call_next = AsyncMock(side_effect=tool_error)

with caplog_for_fastmcp(caplog):
with caplog.at_level(logging.ERROR):
with pytest.raises(McpError) as exc_info:
await middleware.on_message(mock_context, mock_call_next)

assert isinstance(exc_info.value, McpError)
assert exc_info.value.error.code == -32602
assert "Invalid params: test error" in exc_info.value.error.message
assert "Error in test_method: ToolError: test error" in caplog.text

def test_get_error_stats(self, mock_context):
"""Test getting error statistics."""
middleware = ErrorHandlingMiddleware()
Expand Down
26 changes: 26 additions & 0 deletions tests/server/tasks/test_server_tasks_parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,29 @@ async def shared_name_prompt() -> str:
# Prompt inheriting False (mode="forbidden") raises McpError
with pytest.raises(McpError):
await client.get_prompt("shared_name_prompt", task=True)


async def test_task_with_custom_tool_name():
"""Tools with custom names work correctly as tasks (issue #2642).

When a tool is registered with a custom name different from the function
name, task execution should use the custom name for Docket lookup.
"""
mcp = FastMCP("test", tasks=True)

async def my_function() -> str:
return "result from custom-named tool"

mcp.tool(my_function, name="custom-tool-name")

async with Client(mcp) as client:
# Verify the tool is registered with its custom name in Docket
docket = mcp.docket
assert docket is not None
assert "custom-tool-name" in docket.tasks

# Call the tool as a task using its custom name
task = await client.call_tool("custom-tool-name", task=True)
assert not task.returned_immediately
result = await task
assert result.data == "result from custom-named tool"
44 changes: 44 additions & 0 deletions tests/server/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -733,3 +733,47 @@ def get_token() -> str:
@mcp.resource("auth://{token}/validate")
async def validate(token: str = Depends(get_token)) -> str:
return f"Validating with: {token}"


async def test_toolerror_propagates_from_dependency(mcp: FastMCP):
"""ToolError raised in a dependency should propagate unchanged (issue #2633).

When a dependency raises ToolError, it should not be wrapped in RuntimeError.
This allows developers to use ToolError for validation in dependencies.
"""
from fastmcp.exceptions import ToolError

def validate_client_id() -> str:
raise ToolError("Client ID is required - select a client first")

@mcp.tool()
async def my_tool(client_id: str = Depends(validate_client_id)) -> str:
return f"Working with client: {client_id}"

async with Client(mcp) as client:
# ToolError is converted to an error result by the server
result = await client.call_tool("my_tool", {}, raise_on_error=False)
assert result.is_error
# The original error message should be preserved (not wrapped in RuntimeError)
assert result.content[0].text == "Client ID is required - select a client first" # type: ignore[attr-defined]


async def test_validation_error_propagates_from_dependency(mcp: FastMCP):
"""ValidationError raised in a dependency should propagate unchanged."""
from fastmcp.exceptions import ValidationError

def validate_input() -> str:
raise ValidationError("Invalid input format")

@mcp.tool()
async def tool_with_validation(val: str = Depends(validate_input)) -> str:
return val

async with Client(mcp) as client:
# ValidationError is re-raised by the server and becomes an error result
# The original error message should be preserved (not wrapped in RuntimeError)
result = await client.call_tool(
"tool_with_validation", {}, raise_on_error=False
)
assert result.is_error
assert result.content[0].text == "Invalid input format" # type: ignore[attr-defined]
Loading