Repository: jlowin/fastmcp (python)

Issue:
Tool.run should not reveal exception value to the client
### Description

If any exception is raised during a tool call, the exception error message is revealed to the client. This is generally bad practice in python, as private information about the server may be conveyed through the exception. 

[src/fastmcp/tools/tool.py](https://github.com/jlowin/fastmcp/blob/d5db876b603fa6d0d9facc4b65c704786f896609/src/fastmcp/tools/tool.py#L145)
```python
    async def run(
        self,
        arguments: dict[str, Any],
        context: Context[ServerSessionT, LifespanContextT] | None = None,
    ) -> Any:
        """Run the tool with arguments."""
        try:
            return await self.fn_metadata.call_fn_with_arg_validation(
                self.fn,
                self.is_async,
                arguments,
                {self.context_kwarg: context}
                if self.context_kwarg is not None
                else None,
            )
        except Exception as e:
            raise ToolError(f"Error executing tool {self.name}: {e}") from e
```

This exposes the server to vulnerabilities such as information leakage, attack surface mapping, etc.

Ideally whomever is implementing the tool should be handling errors and explicitly raising `ToolError` if the error is meant to be seen by the client. The run definition should be modified to 

```python
    async def run(
        self,
        arguments: dict[str, Any],
        context: Context[ServerSessionT, LifespanContextT] | None = None,
    ) -> Any:
        """Run the tool with arguments."""
        try:
            return await self.fn_metadata.call_fn_with_arg_validation(
                self.fn,
                self.is_async,
                arguments,
                {self.context_kwarg: context}
                if self.context_kwarg is not None
                else None,
            )
        except ToolError:
            # Re-raise if it's a handled error
            raise
        except Exception as e:
            logger.exception(e)
            raise ToolError(f"An unexpected error occurred while executing tool {self.name}")
```

There may be other areas where this is occurring such as resources or prompts, but I have not yet tested to see if those also expose internal errors.

### Version Information

```Text
2.3.3
```

Pull request description:
Improve error handling for tools and resources
Closes #428 

Currently, the contents of all errors are returned to the client. As indicated in #428, this is a security concern. This PR modifies both error handling behavior AND location to unify handling. 

By default, errors are masked, but because it *can* be useful for users to supply additional information so that LLMs can respond appropriately, we use object-specific error classes to bypass the masking behavior on an opt-in basis. 

Now:

- Error contents and messages are *not* transmitted to clients by default, beyond a simple message like "Calling tool 'xyz' failed."
- To transmit error data from an MCP tool, users should raise a `fastmcp.exceptions.ToolError(msg="...")`. The error message will be sent to the client.
- To transmit error data from an MCP resource, users should raise a `fastmcp.exceptions.ResourceError(msg="...")`.


Note:

This PR also changes a behavior which isn't necessarily a "bug" but was undesirable, in that resource template responses were loaded whenever `create_resource()` was called instead of waiting for the resulting resource to actually be read. In some cases this may have led to premature or unecessary work; in others this would lead to runtime errors at an unexpected part of the pipeline. I don't believe this change will have any user-facing consequence under normal conditions.

Patch:
diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx
index 2ccea8d..72d736d 100644
--- a/docs/servers/resources.mdx
+++ b/docs/servers/resources.mdx
@@ -147,6 +147,7 @@ async def read_important_log() -> str:
         return "Log file not found."
 ```
 
+
 ### Resource Classes
 
 While `@mcp.resource` is ideal for dynamic content, you can directly register pre-defined resources (like static files or simple text) using `mcp.add_resource()` and concrete `Resource` subclasses.
@@ -403,17 +404,45 @@ In this stacked decorator pattern:
 - Each parameter defaults to `None` when not included in the URI
 - The function logic handles whichever parameter is provided
 
-**How Templates Work:**
+Templates provide a powerful way to expose parameterized data access points following REST-like principles.
+
+## Error Handling
 
-1.  **Definition:** When FastMCP sees `{...}` placeholders in the `@resource` URI and matching function parameters, it registers a `ResourceTemplate`.
-2.  **Discovery:** Clients list templates via `resources/listResourceTemplates`.
-3.  **Request & Matching:** A client requests a specific URI, e.g., `weather://london/current`. FastMCP matches this to the `weather://{city}/current` template.
-4.  **Parameter Extraction:** It extracts the parameter value: `city="london"`.
-5.  **Type Conversion & Function Call:** It converts extracted values to the types hinted in the function and calls `get_weather(city="london")`.
-6.  **Default Values:** For any function parameters with default values not included in the URI template, FastMCP uses the default values.
-7.  **Response:** The function's return value is formatted (e.g., dict to JSON) and sent back as the resource content.
+<VersionBadge version="2.3.4" />
 
-Templates provide a powerful way to expose parameterized data access points following REST-like principles.
+If your resource function encounters an error, you can raise a standard Python exception (`ValueError`, `TypeError`, `FileNotFoundError`, custom exceptions, etc.) or a FastMCP `ResourceError`.
+
+For security reasons, most exceptions are wrapped in a generic `ResourceError` before being sent to the client, with internal error details masked. However, if you raise a `ResourceError` directly, its contents **are** included in the response. This allows you to provide informative error messages to the client on an opt-in basis.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.exceptions import ResourceError
+
+mcp = FastMCP(name="DataServer")
+
+@mcp.resource("resource://safe-error")
+def fail_with_details() -> str:
+    """This resource provides detailed error information."""
+    # ResourceError contents are sent back to clients
+    raise ResourceError("Unable to retrieve data: file not found")
+
+@mcp.resource("resource://masked-error")
+def fail_with_masked_details() -> str:
+    """This resource masks internal error details."""
+    # Other exceptions are converted to ResourceError with generic message
+    raise ValueError("Sensitive internal file path: /etc/secrets.conf")
+
+@mcp.resource("data://{id}")
+def get_data_by_id(id: str) -> dict:
+    """Template resources also support the same error handling pattern."""
+    if id == "secure":
+        raise ValueError("Cannot access secure data")
+    elif id == "missing":
+        raise ResourceError("Data ID 'missing' not found in database")
+    return {"id": id, "value": "data"}
+```
+
+This error handling pattern applies to both regular resources and resource templates.
 
 ## Server Behavior
 
diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx
index 2d2399f..26468e7 100644
--- a/docs/servers/tools.mdx
+++ b/docs/servers/tools.mdx
@@ -248,27 +248,30 @@ def do_nothing() -> None:
 
 ### Error Handling
 
-If your tool encounters an error, simply raise a standard Python exception (`ValueError`, `TypeError`, `FileNotFoundError`, custom exceptions, etc.).
+<VersionBadge version="2.3.4" />
+
+If your tool encounters an error, you can raise a standard Python exception (`ValueError`, `TypeError`, `FileNotFoundError`, custom exceptions, etc.) or a FastMCP `ToolError`.
+
+In all cases, the exception is logged and converted into an MCP error response to be sent back to the client LLM. For security reasons, the error message is **not** included in the response by default. However, if you raise a `ToolError`, the contents of the exception **are** included in the response. This allows you to provide informative error messages to the client LLM on an opt-in basis, which can help the LLM understand failures and react appropriately.
+
+```python {2, 10, 14}
+from fastmcp import FastMCP
+from fastmcp.exceptions import ToolError
 
-```python
 @mcp.tool()
 def divide(a: float, b: float) -> float:
     """Divide a by b."""
-    if b == 0:
-        # Raise a standard exception
-        raise ValueError("Division by zero is not allowed.")
+
+    # Python exceptions raise errors but the contents are not sent to clients
     if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
         raise TypeError("Both arguments must be numbers.")
+
+    if b == 0:
+        # ToolError contents are sent back to clients
+        raise ToolError("Division by zero is not allowed.")
     return a / b
 ```
 
-FastMCP automatically catches exceptions raised within your tool function:
-1.  It converts the exception into an MCP error response, typically including the exception type and message.
-2.  This error response is sent back to the client/LLM.
-3.  The LLM can then inform the user or potentially try the tool again with different arguments.
-
-Using informative exceptions helps the LLM understand failures and react appropriately.
-
 ### Annotations
 
 <VersionBadge version="2.2.7" />
diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py
index dafe699..4d2bc74 100644
--- a/src/fastmcp/prompts/prompt.py
+++ b/src/fastmcp/prompts/prompt.py
@@ -14,6 +14,7 @@ from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_ca
 
 from fastmcp.server.dependencies import get_context
 from fastmcp.utilities.json_schema import prune_params
+from fastmcp.utilities.logging import get_logger
 from fastmcp.utilities.types import (
     _convert_set_defaults,
     find_kwarg_by_type,
@@ -25,6 +26,8 @@ if TYPE_CHECKING:
 
 CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
 
+logger = get_logger(__name__)
+
 
 def Message(
     content: str | CONTENT_TYPES, role: Role | None = None, **kwargs: Any
@@ -192,13 +195,12 @@ class Prompt(BaseModel):
                             )
                         )
                 except Exception:
-                    raise ValueError(
-                        f"Could not convert prompt result to message: {msg}"
-                    )
+                    raise ValueError("Could not convert prompt result to message.")
 
             return messages
         except Exception as e:
-            raise ValueError(f"Error rendering prompt {self.name}: {e}")
+            logger.exception(f"Error rendering prompt {self.name}: {e}")
+            raise ValueError(f"Error rendering prompt {self.name}.")
 
     def __eq__(self, other: object) -> bool:
         if not isinstance(other, Prompt):
diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py
index aa841cc..9d8a20d 100644
--- a/src/fastmcp/resources/resource_manager.py
+++ b/src/fastmcp/resources/resource_manager.py
@@ -6,7 +6,7 @@ from typing import Any
 
 from pydantic import AnyUrl
 
-from fastmcp.exceptions import NotFoundError
+from fastmcp.exceptions import NotFoundError, ResourceError
 from fastmcp.resources import FunctionResource
 from fastmcp.resources.resource import Resource
 from fastmcp.resources.template import (
@@ -244,11 +244,32 @@ class ResourceManager:
                         uri_str,
                         params=params,
                     )
+                except ResourceError as e:
+                    logger.error(f"Error creating resource from template: {e}")
+                    raise e
                 except Exception as e:
+                    logger.error(f"Error creating resource from template: {e}")
                     raise ValueError(f"Error creating resource from template: {e}")
 
         raise NotFoundError(f"Unknown resource: {uri_str}")
 
+    async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
+        """Read a resource contents."""
+        resource = await self.get_resource(uri)
+
+        try:
+            return await resource.read()
+
+        # raise ResourceErrors as-is
+        except ResourceError as e:
+            logger.error(f"Error reading resource {uri!r}: {e}")
+            raise e
+
+        # raise other exceptions as ResourceErrors without revealing internal details
+        except Exception as e:
+            logger.error(f"Error reading resource {uri!r}: {e}")
+            raise ResourceError(f"Error reading resource {uri!r}") from e
+
     def get_resources(self) -> dict[str, Resource]:
         """Get all registered resources, keyed by URI."""
         return self._resources
diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py
index 7bae355..1335a25 100644
--- a/src/fastmcp/resources/template.py
+++ b/src/fastmcp/resources/template.py
@@ -171,28 +171,27 @@ class ResourceTemplate(BaseModel):
         """Create a resource from the template with the given parameters."""
         from fastmcp.server.context import Context
 
-        try:
-            # Add context to parameters if needed
-            kwargs = params.copy()
-            context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
-            if context_kwarg and context_kwarg not in kwargs:
-                kwargs[context_kwarg] = get_context()
+        # Add context to parameters if needed
+        kwargs = params.copy()
+        context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
+        if context_kwarg and context_kwarg not in kwargs:
+            kwargs[context_kwarg] = get_context()
 
+        async def resource_read_fn() -> str | bytes:
             # Call function and check if result is a coroutine
             result = self.fn(**kwargs)
             if inspect.iscoroutine(result):
                 result = await result
-
-            return FunctionResource(
-                uri=AnyUrl(uri),  # Explicitly convert to AnyUrl
-                name=self.name,
-                description=self.description,
-                mime_type=self.mime_type,
-                fn=lambda **kwargs: result,  # Capture result in closure
-                tags=self.tags,
-            )
-        except Exception as e:
-            raise ValueError(f"Error creating resource from template: {e}")
+            return result
+
+        return FunctionResource(
+            uri=AnyUrl(uri),  # Explicitly convert to AnyUrl
+            name=self.name,
+            description=self.description,
+            mime_type=self.mime_type,
+            fn=resource_read_fn,
+            tags=self.tags,
+        )
 
     def __eq__(self, other: object) -> bool:
         if not isinstance(other, ResourceTemplate):
diff --git a/src/fastmcp/resources/types.py b/src/fastmcp/resources/types.py
index cec2ca8..f1b9ff7 100644
--- a/src/fastmcp/resources/types.py
+++ b/src/fastmcp/resources/types.py
@@ -6,7 +6,7 @@ import inspect
 import json
 from collections.abc import Callable
 from pathlib import Path
-from typing import TYPE_CHECKING, Any
+from typing import Any
 
 import anyio
 import anyio.to_thread
@@ -15,12 +15,13 @@ import pydantic.json
 import pydantic_core
 from pydantic import Field, ValidationInfo
 
+from fastmcp.exceptions import ResourceError
 from fastmcp.resources.resource import Resource
 from fastmcp.server.dependencies import get_context
+from fastmcp.utilities.logging import get_logger
 from fastmcp.utilities.types import find_kwarg_by_type
 
-if TYPE_CHECKING:
-    pass
+logger = get_logger(__name__)
 
 
 class TextResource(Resource):
@@ -62,26 +63,23 @@ class FunctionResource(Resource):
         """Read the resource by calling the wrapped function."""
         from fastmcp.server.context import Context
 
-        try:
-            kwargs = {}
-            context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
-            if context_kwarg is not None:
-                kwargs[context_kwarg] = get_context()
-
-            result = self.fn(**kwargs)
-            if inspect.iscoroutinefunction(self.fn):
-                result = await result
-
-            if isinstance(result, Resource):
-                return await result.read()
-            elif isinstance(result, bytes):
-                return result
-            elif isinstance(result, str):
-                return result
-            else:
-                return pydantic_core.to_json(result, fallback=str, indent=2).decode()
-        except Exception as e:
-            raise ValueError(f"Error reading resource {self.uri}: {e}")
+        kwargs = {}
+        context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
+        if context_kwarg is not None:
+            kwargs[context_kwarg] = get_context()
+
+        result = self.fn(**kwargs)
+        if inspect.iscoroutinefunction(self.fn):
+            result = await result
+
+        if isinstance(result, Resource):
+            return await result.read()
+        elif isinstance(result, bytes):
+            return result
+        elif isinstance(result, str):
+            return result
+        else:
+            return pydantic_core.to_json(result, fallback=str, indent=2).decode()
 
 
 class FileResource(Resource):
@@ -124,7 +122,7 @@ class FileResource(Resource):
                 return await anyio.to_thread.run_sync(self.path.read_bytes)
             return await anyio.to_thread.run_sync(self.path.read_text)
         except Exception as e:
-            raise ValueError(f"Error reading file {self.path}: {e}")
+            raise ResourceError(f"Error reading file {self.path}") from e
 
 
 class HttpResource(Resource):
@@ -185,7 +183,7 @@ class DirectoryResource(Resource):
                 else list(self.path.rglob("*"))
             )
         except Exception as e:
-            raise ValueError(f"Error listing directory {self.path}: {e}")
+            raise ResourceError(f"Error listing directory {self.path}: {e}")
 
     async def read(self) -> str:  # Always returns JSON string
         """Read the directory listing."""
@@ -193,5 +191,5 @@ class DirectoryResource(Resource):
             files = await anyio.to_thread.run_sync(self.list_files)
             file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
             return json.dumps({"files": file_list}, indent=2)
-        except Exception as e:
-            raise ValueError(f"Error reading directory {self.path}: {e}")
+        except Exception:
+            raise ResourceError(f"Error reading directory {self.path}")
diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py
index b6316c8..fd46ac3 100644
--- a/src/fastmcp/server/openapi.py
+++ b/src/fastmcp/server/openapi.py
@@ -14,6 +14,7 @@ import httpx
 from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
 from pydantic.networks import AnyUrl
 
+from fastmcp.exceptions import ToolError
 from fastmcp.resources import Resource, ResourceTemplate
 from fastmcp.server.server import FastMCP
 from fastmcp.tools.tool import Tool, _convert_to_content
@@ -163,7 +164,7 @@ class OpenAPITool(Tool):
         }
         missing_params = required_path_params - path_params.keys()
         if missing_params:
-            raise ValueError(f"Missing required path parameters: {missing_params}")
+            raise ToolError(f"Missing required path parameters: {missing_params}")
 
         for param_name, param_value in path_params.items():
             path = path.replace(f"{{{param_name}}}", str(param_value))
diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py
index 3ac7e6e..8f7123b 100644
--- a/src/fastmcp/server/proxy.py
+++ b/src/fastmcp/server/proxy.py
@@ -18,7 +18,7 @@ from mcp.types import (
 from pydantic.networks import AnyUrl
 
 from fastmcp.client import Client
-from fastmcp.exceptions import NotFoundError
+from fastmcp.exceptions import NotFoundError, ResourceError, ToolError
 from fastmcp.prompts import Prompt, PromptMessage
 from fastmcp.resources import Resource, ResourceTemplate
 from fastmcp.server.context import Context
@@ -64,7 +64,7 @@ class ProxyTool(Tool):
                 arguments=arguments,
             )
         if result.isError:
-            raise ValueError(cast(mcp.types.TextContent, result.content[0]).text)
+            raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
         return result.content
 
 
@@ -97,7 +97,7 @@ class ProxyResource(Resource):
         elif isinstance(result[0], BlobResourceContents):
             return result[0].blob
         else:
-            raise ValueError(f"Unsupported content type: {type(result[0])}")
+            raise ResourceError(f"Unsupported content type: {type(result[0])}")
 
 
 class ProxyTemplate(ResourceTemplate):
@@ -138,7 +138,7 @@ class ProxyTemplate(ResourceTemplate):
         elif isinstance(result[0], BlobResourceContents):
             value = result[0].blob
         else:
-            raise ValueError(f"Unsupported content type: {type(result[0])}")
+            raise ResourceError(f"Unsupported content type: {type(result[0])}")
 
         return ProxyResource(
             client=self._client,
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index b17b4ec..b24051d 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -43,7 +43,7 @@ from starlette.routing import BaseRoute, Route
 
 import fastmcp.server
 import fastmcp.settings
-from fastmcp.exceptions import NotFoundError, ResourceError
+from fastmcp.exceptions import NotFoundError
 from fastmcp.prompts import Prompt, PromptManager
 from fastmcp.prompts.prompt import PromptResult
 from fastmcp.resources import Resource, ResourceManager
@@ -385,16 +385,13 @@ class FastMCP(Generic[LifespanResultT]):
         with fastmcp.server.context.Context(fastmcp=self):
             if self._resource_manager.has_resource(uri):
                 resource = await self._resource_manager.get_resource(uri)
-                try:
-                    content = await resource.read()
-                    return [
-                        ReadResourceContents(
-                            content=content, mime_type=resource.mime_type
-                        )
-                    ]
-                except Exception as e:
-                    logger.error(f"Error reading resource {uri}: {e}")
-                    raise ResourceError(str(e))
+                content = await self._resource_manager.read_resource(uri)
+                return [
+                    ReadResourceContents(
+                        content=content,
+                        mime_type=resource.mime_type,
+                    )
+                ]
             else:
                 for server in self._mounted_servers.values():
                     if server.match_resource(str(uri)):
diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py
index fd6cdca..aa7c6bf 100644
--- a/src/fastmcp/tools/tool.py
+++ b/src/fastmcp/tools/tool.py
@@ -11,7 +11,6 @@ from mcp.types import Tool as MCPTool
 from pydantic import BaseModel, BeforeValidator, Field
 
 import fastmcp
-from fastmcp.exceptions import ToolError
 from fastmcp.server.dependencies import get_context
 from fastmcp.utilities.json_schema import prune_params
 from fastmcp.utilities.logging import get_logger
@@ -102,49 +101,45 @@ class Tool(BaseModel):
 
         arguments = arguments.copy()
 
-        try:
-            context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
-            if context_kwarg and context_kwarg not in arguments:
-                arguments[context_kwarg] = get_context()
-
-            if fastmcp.settings.settings.tool_attempt_parse_json_args:
-                # Pre-parse data from JSON in order to handle cases like `["a", "b", "c"]`
-                # being passed in as JSON inside a string rather than an actual list.
-                #
-                # Claude desktop is prone to this - in fact it seems incapable of NOT doing
-                # this. For sub-models, it tends to pass dicts (JSON objects) as JSON strings,
-                # which can be pre-parsed here.
-                signature = inspect.signature(self.fn)
-                for param_name in self.parameters["properties"]:
-                    arg = arguments.get(param_name, None)
-                    # if not in signature, we won't have annotations, so skip logic
-                    if param_name not in signature.parameters:
-                        continue
-                    # if not a string, we won't have a JSON to parse, so skip logic
-                    if not isinstance(arg, str):
-                        continue
-                    # skip if the type is a simple type (int, float, bool)
-                    if signature.parameters[param_name].annotation in (
-                        int,
-                        float,
-                        bool,
-                    ):
-                        continue
-                    try:
-                        arguments[param_name] = json.loads(arg)
-
-                    except json.JSONDecodeError:
-                        pass
-
-            type_adapter = get_cached_typeadapter(self.fn)
-            result = type_adapter.validate_python(arguments)
-            if inspect.isawaitable(result):
-                result = await result
-
-            return _convert_to_content(result, serializer=self.serializer)
-        except Exception as e:
-            logger.exception(f"Tool {self.name} failed")
-            raise ToolError(f"Error executing tool {self.name}: {e}") from e
+        context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
+        if context_kwarg and context_kwarg not in arguments:
+            arguments[context_kwarg] = get_context()
+
+        if fastmcp.settings.settings.tool_attempt_parse_json_args:
+            # Pre-parse data from JSON in order to handle cases like `["a", "b", "c"]`
+            # being passed in as JSON inside a string rather than an actual list.
+            #
+            # Claude desktop is prone to this - in fact it seems incapable of NOT doing
+            # this. For sub-models, it tends to pass dicts (JSON objects) as JSON strings,
+            # which can be pre-parsed here.
+            signature = inspect.signature(self.fn)
+            for param_name in self.parameters["properties"]:
+                arg = arguments.get(param_name, None)
+                # if not in signature, we won't have annotations, so skip logic
+                if param_name not in signature.parameters:
+                    continue
+                # if not a string, we won't have a JSON to parse, so skip logic
+                if not isinstance(arg, str):
+                    continue
+                # skip if the type is a simple type (int, float, bool)
+                if signature.parameters[param_name].annotation in (
+                    int,
+                    float,
+                    bool,
+                ):
+                    continue
+                try:
+                    arguments[param_name] = json.loads(arg)
+
+                except json.JSONDecodeError:
+                    pass
+
+        type_adapter = get_cached_typeadapter(self.fn)
+        result = type_adapter.validate_python(arguments)
+        if inspect.isawaitable(result):
+            result = await result
+
+        return _convert_to_content(result, serializer=self.serializer)
 
     def to_mcp_tool(self, **overrides: Any) -> MCPTool:
         kwargs = {
diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py
index 21e38c5..2fd1638 100644
--- a/src/fastmcp/tools/tool_manager.py
+++ b/src/fastmcp/tools/tool_manager.py
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any
 
 from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
 
-from fastmcp.exceptions import NotFoundError
+from fastmcp.exceptions import NotFoundError, ToolError
 from fastmcp.settings import DuplicateBehavior
 from fastmcp.tools.tool import Tool
 from fastmcp.utilities.logging import get_logger
@@ -102,4 +102,15 @@ class ToolManager:
         if not tool:
             raise NotFoundError(f"Unknown tool: {key}")
 
-        return await tool.run(arguments)
+        try:
+            return await tool.run(arguments)
+
+        # raise ToolErrors as-is
+        except ToolError as e:
+            logger.exception(f"Error calling tool {key!r}: {e}")
+            raise e
+
+        # raise other exceptions as ToolErrors without revealing internal details
+        except Exception as e:
+            logger.exception(f"Error calling tool {key!r}: {e}")
+            raise ToolError(f"Error calling tool {key!r}") from e


Test patch:
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
index dcb1624..ff460b2 100644
--- a/tests/client/test_client.py
+++ b/tests/client/test_client.py
@@ -5,6 +5,7 @@ from pydantic import AnyUrl
 
 from fastmcp.client import Client
 from fastmcp.client.transports import FastMCPTransport
+from fastmcp.exceptions import ResourceError, ToolError
 from fastmcp.prompts.prompt import TextContent
 from fastmcp.server.server import FastMCP
 
@@ -404,3 +405,97 @@ async def test_tagged_template_functionality(tagged_resources_server):
         content_str = str(result[0])
         assert '"id": "123"' in content_str
         assert '"type": "template_data"' in content_str
+
+
+class TestErrorHandling:
+    async def test_general_tool_exceptions_are_masked(self):
+        mcp = FastMCP("TestServer")
+
+        @mcp.tool()
+        def error_tool():
+            raise ValueError("This is a test error (abc)")
+
+        client = Client(transport=FastMCPTransport(mcp))
+
+        async with client:
+            result = await client.call_tool_mcp("error_tool", {})
+            assert result.isError
+            assert isinstance(result.content[0], TextContent)
+            assert "test error" not in result.content[0].text
+            assert "abc" not in result.content[0].text
+
+    async def test_specific_tool_errors_are_sent_to_client(self):
+        mcp = FastMCP("TestServer")
+
+        @mcp.tool()
+        def custom_error_tool():
+            raise ToolError("This is a test error (abc)")
+
+        client = Client(transport=FastMCPTransport(mcp))
+
+        async with client:
+            result = await client.call_tool_mcp("custom_error_tool", {})
+            assert result.isError
+            assert isinstance(result.content[0], TextContent)
+            assert "test error" in result.content[0].text
+            assert "abc" in result.content[0].text
+
+    async def test_general_resource_exceptions_are_masked(self):
+        mcp = FastMCP("TestServer")
+
+        @mcp.resource(uri="exception://resource")
+        async def exception_resource():
+            raise ValueError("This is an internal error (sensitive)")
+
+        client = Client(transport=FastMCPTransport(mcp))
+
+        async with client:
+            with pytest.raises(Exception) as excinfo:
+                await client.read_resource(AnyUrl("exception://resource"))
+            assert "Error reading resource" in str(excinfo.value)
+            assert "sensitive" not in str(excinfo.value)
+            assert "internal error" not in str(excinfo.value)
+
+    async def test_resource_errors_are_sent_to_client(self):
+        mcp = FastMCP("TestServer")
+
+        @mcp.resource(uri="error://resource")
+        async def error_resource():
+            raise ResourceError("This is a resource error (xyz)")
+
+        client = Client(transport=FastMCPTransport(mcp))
+
+        async with client:
+            with pytest.raises(Exception) as excinfo:
+                await client.read_resource(AnyUrl("error://resource"))
+            assert "This is a resource error (xyz)" in str(excinfo.value)
+
+    async def test_general_template_exceptions_are_masked(self):
+        mcp = FastMCP("TestServer")
+
+        @mcp.resource(uri="exception://resource/{id}")
+        async def exception_resource(id: str):
+            raise ValueError("This is an internal error (sensitive)")
+
+        client = Client(transport=FastMCPTransport(mcp))
+
+        async with client:
+            with pytest.raises(Exception) as excinfo:
+                await client.read_resource(AnyUrl("exception://resource/123"))
+            assert "Error reading resource" in str(excinfo.value)
+            assert "sensitive" not in str(excinfo.value)
+            assert "internal error" not in str(excinfo.value)
+
+    async def test_template_errors_are_sent_to_client(self):
+        mcp = FastMCP("TestServer")
+
+        @mcp.resource(uri="error://resource/{id}")
+        async def error_resource(id: str):
+            raise ResourceError("This is a resource error (xyz)")
+
+        client = Client(transport=FastMCPTransport(mcp))
+
+        async with client:
+            with pytest.raises(Exception) as excinfo:
+                await client.read_resource(AnyUrl("error://resource/123"))
+            assert "This is a resource error (xyz)" in str(excinfo.value)
diff --git a/tests/contrib/test_bulk_tool_caller.py b/tests/contrib/test_bulk_tool_caller.py
index 348eb0e..8553419 100644
--- a/tests/contrib/test_bulk_tool_caller.py
+++ b/tests/contrib/test_bulk_tool_caller.py
@@ -27,8 +27,7 @@ async def error_tool(arg1: str) -> dict[str, Any]:
 def error_tool_result_factory(arg1: str) -> CallToolRequestResult:
     """Generates the expected error result for error_tool."""
     # Mimic the error message format generated by BulkToolCaller when catching ToolException
-    exception_message = f"Error in tool with arg1: {arg1}"
-    formatted_error_text = f"Error executing tool error_tool: {exception_message}"
+    formatted_error_text = "Error calling tool 'error_tool'"
     return CallToolRequestResult(
         isError=True,
         content=[TextContent(text=formatted_error_text, type="text")],
diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py
index 598e9d8..cb62222 100644
--- a/tests/resources/test_file_resources.py
+++ b/tests/resources/test_file_resources.py
@@ -5,6 +5,7 @@ from tempfile import NamedTemporaryFile
 import pytest
 from pydantic import FileUrl
 
+from fastmcp.exceptions import ResourceError
 from fastmcp.resources import FileResource
 
 
@@ -94,7 +95,7 @@ class TestFileResource:
             name="test",
             path=missing,
         )
-        with pytest.raises(ValueError, match="Error reading file"):
+        with pytest.raises(ResourceError, match="Error reading file"):
             await resource.read()
 
     @pytest.mark.skipif(
@@ -109,7 +110,7 @@ class TestFileResource:
                 name="test",
                 path=temp_file,
             )
-            with pytest.raises(ValueError, match="Error reading file"):
+            with pytest.raises(ResourceError, match="Error reading file"):
                 await resource.read()
         finally:
             temp_file.chmod(0o644)  # Restore permissions
diff --git a/tests/resources/test_function_resources.py b/tests/resources/test_function_resources.py
index 46ac320..8ebbe27 100644
--- a/tests/resources/test_function_resources.py
+++ b/tests/resources/test_function_resources.py
@@ -80,7 +80,7 @@ class TestFunctionResource:
             name="test",
             fn=failing_func,
         )
-        with pytest.raises(ValueError, match="Error reading resource function://test"):
+        with pytest.raises(ValueError, match="Test error"):
             await resource.read()
 
     async def test_basemodel_conversion(self):
diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py
index 2934631..006911c 100644
--- a/tests/resources/test_resource_manager.py
+++ b/tests/resources/test_resource_manager.py
@@ -4,7 +4,7 @@ from tempfile import NamedTemporaryFile
 import pytest
 from pydantic import AnyUrl, FileUrl
 
-from fastmcp.exceptions import NotFoundError
+from fastmcp.exceptions import NotFoundError, ResourceError
 from fastmcp.resources import (
     FileResource,
     FunctionResource,
@@ -540,3 +540,87 @@ class TestCustomResourceKeys:
         # Shouldn't work with the original template pattern
         with pytest.raises(NotFoundError, match="Unknown resource"):
             await manager.get_resource("greet://world")
+
+
+class TestResourceErrorHandling:
+    """Test error handling in the ResourceManager."""
+
+    async def test_resource_error_passthrough(self):
+        """Test that ResourceErrors are passed through directly."""
+        manager = ResourceManager()
+
+        async def error_resource():
+            """Resource that raises a ResourceError."""
+            raise ResourceError("Specific resource error")
+
+        resource = FunctionResource(
+            uri=AnyUrl("error://resource"),
+            name="error_resource",
+            fn=error_resource,
+        )
+        manager.add_resource(resource)
+
+        with pytest.raises(ResourceError, match="Specific resource error"):
+            await manager.read_resource("error://resource")
+
+    async def test_exception_converted_to_resource_error(self):
+        """Test that other exceptions are converted to ResourceError."""
+        manager = ResourceManager()
+
+        async def buggy_resource():
+            """Resource that raises a ValueError."""
+            raise ValueError("Internal error details")
+
+        resource = FunctionResource(
+            uri=AnyUrl("buggy://resource"),
+            name="buggy_resource",
+            fn=buggy_resource,
+        )
+        manager.add_resource(resource)
+
+        with pytest.raises(ResourceError) as excinfo:
+            await manager.read_resource("buggy://resource")
+
+        # Exception message should contain the resource URI but not the internal details
+        assert "Error reading resource 'buggy://resource'" in str(excinfo.value)
+        assert "Internal error details" not in str(excinfo.value)
+
+    async def test_template_resource_error_passthrough(self):
+        """Test that ResourceErrors from template-generated resources are passed through."""
+        manager = ResourceManager()
+
+        def error_template(param: str):
+            """Template that raises a ResourceError."""
+            raise ResourceError(f"Template error with param {param}")
+
+        template = ResourceTemplate.from_function(
+            fn=error_template,
+            uri_template="error://{param}",
+            name="error_template",
+        )
+        manager.add_template(template)
+
+        with pytest.raises(ResourceError) as excinfo:
+            await manager.read_resource("error://test")
+
+        # The original error message should be included in the ValueError
+        assert "Template error with param test" in str(excinfo.value)
+
+    async def test_template_exception_converted_to_resource_error(self):
+        """Test that other exceptions from template-generated resources are converted."""
+        manager = ResourceManager()
+
+        def buggy_template(param: str):
+            """Template that raises a ValueError."""
+            raise ValueError(f"Internal template error with {param}")
+
+        template = ResourceTemplate.from_function(
+            fn=buggy_template,
+            uri_template="buggy://{param}",
+            name="buggy_template",
+        )
+        manager.add_template(template)
+
+        # First, the template creation will fail with ValueError
+        with pytest.raises(ResourceError, match="Error reading resource"):
+            await manager.read_resource("buggy://test")
diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py
index 00f2b01..089b4ab 100644
--- a/tests/resources/test_resource_template.py
+++ b/tests/resources/test_resource_template.py
@@ -186,21 +186,6 @@ class TestResourceTemplate:
         data = json.loads(content)
         assert data == {"key": "foo", "value": 123}
 
-    async def test_template_error(self):
-        """Test error handling in template resource creation."""
-
-        def failing_func(x: str) -> str:
-            raise ValueError("Test error")
-
-        template = ResourceTemplate.from_function(
-            fn=failing_func,
-            uri_template="fail://{x}",
-            name="fail",
-        )
-
-        with pytest.raises(ValueError, match="Error creating resource from template"):
-            await template.create_resource("fail://test", {"x": "test"})
-
     async def test_async_text_resource(self):
         """Test creating a text resource from async function."""
 
diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py
index 37b8284..b96b2be 100644
--- a/tests/server/test_server_interactions.py
+++ b/tests/server/test_server_interactions.py
@@ -17,6 +17,7 @@ from mcp.types import (
 from pydantic import AnyUrl, Field
 
 from fastmcp import Client, Context, FastMCP
+from fastmcp.client.transports import FastMCPTransport
 from fastmcp.exceptions import ClientError
 from fastmcp.prompts.prompt import EmbeddedResource, PromptMessage
 from fastmcp.resources import FileResource, FunctionResource
@@ -94,12 +95,19 @@ class TestTools:
             with pytest.raises(Exception):
                 await client.call_tool("error_tool", {})
 
-    async def test_call_tool_error_as_client_raw(self, tool_server: FastMCP):
-        async with Client(tool_server) as client:
-            result = await client.call_tool_mcp("error_tool", {})
-        assert result.isError
-        assert isinstance(result.content[0], TextContent)
-        assert "Test error" in result.content[0].text
+    async def test_call_tool_error_as_client_raw(self):
+        """Test raising and catching errors from a tool."""
+        mcp = FastMCP()
+        client = Client(transport=FastMCPTransport(mcp))
+
+        @mcp.tool()
+        def error_tool():
+            raise ValueError("Test error")
+
+        async with client:
+            with pytest.raises(Exception) as excinfo:
+                await client.call_tool("error_tool", {})
+            assert "Error calling tool 'error_tool'" in str(excinfo.value)
 
     async def test_tool_returns_list(self, tool_server: FastMCP):
         async with Client(tool_server) as client:
@@ -313,7 +321,7 @@ class TestToolParameters:
         async with Client(mcp) as client:
             with pytest.raises(
                 ClientError,
-                match="Input should be a valid integer, unable to parse string as an integer",
+                match="Error calling tool 'my_tool'",
             ):
                 await client.call_tool("my_tool", {"x": "not an int"})
 
@@ -357,10 +365,7 @@ class TestToolParameters:
             pass
 
         async with Client(mcp) as client:
-            with pytest.raises(
-                ClientError,
-                match="Input should be greater than or equal to 1",
-            ):
+            with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
                 await client.call_tool("analyze", {"x": 0})
 
     async def test_default_field_validation(self):
@@ -371,10 +376,7 @@ class TestToolParameters:
             pass
 
         async with Client(mcp) as client:
-            with pytest.raises(
-                ClientError,
-                match="Input should be greater than or equal to 1",
-            ):
+            with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
                 await client.call_tool("analyze", {"x": 0})
 
     async def test_default_field_is_still_required_if_no_default_specified(self):
@@ -385,7 +387,7 @@ class TestToolParameters:
             pass
 
         async with Client(mcp) as client:
-            with pytest.raises(ClientError, match="Missing required argument"):
+            with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
                 await client.call_tool("analyze", {})
 
     async def test_literal_type_validation_error(self):
@@ -396,7 +398,7 @@ class TestToolParameters:
             pass
 
         async with Client(mcp) as client:
-            with pytest.raises(ClientError, match="Input should be 'a' or 'b'"):
+            with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
                 await client.call_tool("analyze", {"x": "c"})
 
     async def test_literal_type_validation_success(self):
@@ -424,9 +426,7 @@ class TestToolParameters:
             return x.value
 
         async with Client(mcp) as client:
-            with pytest.raises(
-                ClientError, match="Input should be 'red', 'green' or 'blue'"
-            ):
+            with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
                 await client.call_tool("analyze", {"x": "some-color"})
 
     async def test_enum_type_validation_success(self):
@@ -462,7 +462,7 @@ class TestToolParameters:
             assert isinstance(result[0], TextContent)
             assert result[0].text == "1.0"
 
-            with pytest.raises(ClientError, match="2 validation errors"):
+            with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
                 await client.call_tool("analyze", {"x": "not a number"})
 
     async def test_path_type(self):
@@ -489,7 +489,7 @@ class TestToolParameters:
             return str(path)
 
         async with Client(mcp) as client:
-            with pytest.raises(ClientError, match="Input is not a valid path"):
+            with pytest.raises(ClientError, match="Error calling tool 'send_path'"):
                 await client.call_tool("send_path", {"path": 1})
 
     async def test_uuid_type(self):
@@ -515,7 +515,7 @@ class TestToolParameters:
             return str(x)
 
         async with Client(mcp) as client:
-            with pytest.raises(ClientError, match="Input should be a valid UUID"):
+            with pytest.raises(ClientError, match="Error calling tool 'send_uuid'"):
                 await client.call_tool("send_uuid", {"x": "not a uuid"})
 
     async def test_datetime_type(self):
@@ -554,7 +554,7 @@ class TestToolParameters:
             return x.isoformat()
 
         async with Client(mcp) as client:
-            with pytest.raises(ClientError, match="Input should be a valid datetime"):
+            with pytest.raises(ClientError, match="Error calling tool 'send_datetime'"):
                 await client.call_tool("send_datetime", {"x": "not a datetime"})
 
     async def test_date_type(self):
diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py
index 249c681..5fe4470 100644
--- a/tests/tools/test_tool.py
+++ b/tests/tools/test_tool.py
@@ -300,7 +300,7 @@ class TestLegacyToolJsonParsing:
         async with Client(mcp) as client:
             with pytest.raises(
                 ClientError,
-                match="Input should be a valid list",
+                match="Error calling tool 'process_list'",
             ):
                 await client.call_tool("process_list", {"items": "['a', 'b', 3]"})
 
diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py
index a777b75..b469b31 100644
--- a/tests/tools/test_tool_manager.py
+++ b/tests/tools/test_tool_manager.py
@@ -643,7 +643,7 @@ class TestContextHandling:
 
         with context:
             with pytest.raises(
-                ToolError, match="Error executing tool tool_with_context"
+                ToolError, match="Error calling tool 'tool_with_context'"
             ):
                 await manager.call_tool("tool_with_context", {"x": 42})
 
@@ -740,3 +740,67 @@ class TestCustomToolNames:
 
         # But the function is different
         assert stored_tool.fn.__name__ == "replacement_fn"
+
+
+class TestToolErrorHandling:
+    """Test error handling in the ToolManager."""
+
+    async def test_tool_error_passthrough(self):
+        """Test that ToolErrors are passed through directly."""
+        manager = ToolManager()
+
+        def error_tool(x: int) -> int:
+            """Tool that raises a ToolError."""
+            raise ToolError("Specific tool error")
+
+        manager.add_tool_from_fn(error_tool)
+
+        with pytest.raises(ToolError, match="Specific tool error"):
+            await manager.call_tool("error_tool", {"x": 42})
+
+    async def test_exception_converted_to_tool_error(self):
+        """Test that other exceptions are converted to ToolError."""
+        manager = ToolManager()
+
+        def buggy_tool(x: int) -> int:
+            """Tool that raises a ValueError."""
+            raise ValueError("Internal error details")
+
+        manager.add_tool_from_fn(buggy_tool)
+
+        with pytest.raises(ToolError) as excinfo:
+            await manager.call_tool("buggy_tool", {"x": 42})
+
+        # Exception message should contain the tool name but not the internal details
+        assert "Error calling tool 'buggy_tool'" in str(excinfo.value)
+        assert "Internal error details" not in str(excinfo.value)
+
+    async def test_async_tool_error_passthrough(self):
+        """Test that ToolErrors from async tools are passed through directly."""
+        manager = ToolManager()
+
+        async def async_error_tool(x: int) -> int:
+            """Async tool that raises a ToolError."""
+            raise ToolError("Async tool error")
+
+        manager.add_tool_from_fn(async_error_tool)
+
+        with pytest.raises(ToolError, match="Async tool error"):
+            await manager.call_tool("async_error_tool", {"x": 42})
+
+    async def test_async_exception_converted_to_tool_error(self):
+        """Test that other exceptions from async tools are converted to ToolError."""
+        manager = ToolManager()
+
+        async def async_buggy_tool(x: int) -> int:
+            """Async tool that raises a ValueError."""
+            raise ValueError("Internal async error details")
+
+        manager.add_tool_from_fn(async_buggy_tool)
+
+        with pytest.raises(ToolError) as excinfo:
+            await manager.call_tool("async_buggy_tool", {"x": 42})
+
+        # Exception message should contain the tool name but not the internal details
+        assert "Error calling tool 'async_buggy_tool'" in str(excinfo.value)
+        assert "Internal async error details" not in str(excinfo.value)