Jeremiah Lowin commited on
Commit
2c75133
·
1 Parent(s): d2d17d2

Improve error handling for tools and resources

Browse files
docs/servers/resources.mdx CHANGED
@@ -147,6 +147,7 @@ async def read_important_log() -> str:
147
  return "Log file not found."
148
  ```
149
 
 
150
  ### Resource Classes
151
 
152
  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:
403
  - Each parameter defaults to `None` when not included in the URI
404
  - The function logic handles whichever parameter is provided
405
 
406
- **How Templates Work:**
 
 
407
 
408
- 1. **Definition:** When FastMCP sees `{...}` placeholders in the `@resource` URI and matching function parameters, it registers a `ResourceTemplate`.
409
- 2. **Discovery:** Clients list templates via `resources/listResourceTemplates`.
410
- 3. **Request & Matching:** A client requests a specific URI, e.g., `weather://london/current`. FastMCP matches this to the `weather://{city}/current` template.
411
- 4. **Parameter Extraction:** It extracts the parameter value: `city="london"`.
412
- 5. **Type Conversion & Function Call:** It converts extracted values to the types hinted in the function and calls `get_weather(city="london")`.
413
- 6. **Default Values:** For any function parameters with default values not included in the URI template, FastMCP uses the default values.
414
- 7. **Response:** The function's return value is formatted (e.g., dict to JSON) and sent back as the resource content.
415
 
416
- Templates provide a powerful way to expose parameterized data access points following REST-like principles.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
 
418
  ## Server Behavior
419
 
 
147
  return "Log file not found."
148
  ```
149
 
150
+
151
  ### Resource Classes
152
 
153
  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.
 
404
  - Each parameter defaults to `None` when not included in the URI
405
  - The function logic handles whichever parameter is provided
406
 
407
+ Templates provide a powerful way to expose parameterized data access points following REST-like principles.
408
+
409
+ ## Error Handling
410
 
411
+ <VersionBadge version="2.3.4" />
 
 
 
 
 
 
412
 
413
+ If your resource function encounters an error, you can raise a standard Python exception (`ValueError`, `TypeError`, `FileNotFoundError`, custom exceptions, etc.) or a FastMCP `ResourceError`.
414
+
415
+ 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.
416
+
417
+ ```python
418
+ from fastmcp import FastMCP
419
+ from fastmcp.exceptions import ResourceError
420
+
421
+ mcp = FastMCP(name="DataServer")
422
+
423
+ @mcp.resource("resource://safe-error")
424
+ def fail_with_details() -> str:
425
+ """This resource provides detailed error information."""
426
+ # ResourceError contents are sent back to clients
427
+ raise ResourceError("Unable to retrieve data: file not found")
428
+
429
+ @mcp.resource("resource://masked-error")
430
+ def fail_with_masked_details() -> str:
431
+ """This resource masks internal error details."""
432
+ # Other exceptions are converted to ResourceError with generic message
433
+ raise ValueError("Sensitive internal file path: /etc/secrets.conf")
434
+
435
+ @mcp.resource("data://{id}")
436
+ def get_data_by_id(id: str) -> dict:
437
+ """Template resources also support the same error handling pattern."""
438
+ if id == "secure":
439
+ raise ValueError("Cannot access secure data")
440
+ elif id == "missing":
441
+ raise ResourceError("Data ID 'missing' not found in database")
442
+ return {"id": id, "value": "data"}
443
+ ```
444
+
445
+ This error handling pattern applies to both regular resources and resource templates.
446
 
447
  ## Server Behavior
448
 
docs/servers/tools.mdx CHANGED
@@ -248,27 +248,30 @@ def do_nothing() -> None:
248
 
249
  ### Error Handling
250
 
251
- If your tool encounters an error, simply raise a standard Python exception (`ValueError`, `TypeError`, `FileNotFoundError`, custom exceptions, etc.).
 
 
 
 
 
 
 
 
252
 
253
- ```python
254
  @mcp.tool()
255
  def divide(a: float, b: float) -> float:
256
  """Divide a by b."""
257
- if b == 0:
258
- # Raise a standard exception
259
- raise ValueError("Division by zero is not allowed.")
260
  if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
261
  raise TypeError("Both arguments must be numbers.")
 
 
 
 
262
  return a / b
263
  ```
264
 
265
- FastMCP automatically catches exceptions raised within your tool function:
266
- 1. It converts the exception into an MCP error response, typically including the exception type and message.
267
- 2. This error response is sent back to the client/LLM.
268
- 3. The LLM can then inform the user or potentially try the tool again with different arguments.
269
-
270
- Using informative exceptions helps the LLM understand failures and react appropriately.
271
-
272
  ### Annotations
273
 
274
  <VersionBadge version="2.2.7" />
 
248
 
249
  ### Error Handling
250
 
251
+ <VersionBadge version="2.3.4" />
252
+
253
+ If your tool encounters an error, you can raise a standard Python exception (`ValueError`, `TypeError`, `FileNotFoundError`, custom exceptions, etc.) or a FastMCP `ToolError`.
254
+
255
+ 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.
256
+
257
+ ```python {2, 10, 14}
258
+ from fastmcp import FastMCP
259
+ from fastmcp.exceptions import ToolError
260
 
 
261
  @mcp.tool()
262
  def divide(a: float, b: float) -> float:
263
  """Divide a by b."""
264
+
265
+ # Python exceptions raise errors but the contents are not sent to clients
 
266
  if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
267
  raise TypeError("Both arguments must be numbers.")
268
+
269
+ if b == 0:
270
+ # ToolError contents are sent back to clients
271
+ raise ToolError("Division by zero is not allowed.")
272
  return a / b
273
  ```
274
 
 
 
 
 
 
 
 
275
  ### Annotations
276
 
277
  <VersionBadge version="2.2.7" />
src/fastmcp/prompts/prompt.py CHANGED
@@ -14,6 +14,7 @@ from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_ca
14
 
15
  from fastmcp.server.dependencies import get_context
16
  from fastmcp.utilities.json_schema import prune_params
 
17
  from fastmcp.utilities.types import (
18
  _convert_set_defaults,
19
  find_kwarg_by_type,
@@ -25,6 +26,8 @@ if TYPE_CHECKING:
25
 
26
  CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
27
 
 
 
28
 
29
  def Message(
30
  content: str | CONTENT_TYPES, role: Role | None = None, **kwargs: Any
@@ -192,13 +195,12 @@ class Prompt(BaseModel):
192
  )
193
  )
194
  except Exception:
195
- raise ValueError(
196
- f"Could not convert prompt result to message: {msg}"
197
- )
198
 
199
  return messages
200
  except Exception as e:
201
- raise ValueError(f"Error rendering prompt {self.name}: {e}")
 
202
 
203
  def __eq__(self, other: object) -> bool:
204
  if not isinstance(other, Prompt):
 
14
 
15
  from fastmcp.server.dependencies import get_context
16
  from fastmcp.utilities.json_schema import prune_params
17
+ from fastmcp.utilities.logging import get_logger
18
  from fastmcp.utilities.types import (
19
  _convert_set_defaults,
20
  find_kwarg_by_type,
 
26
 
27
  CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
28
 
29
+ logger = get_logger(__name__)
30
+
31
 
32
  def Message(
33
  content: str | CONTENT_TYPES, role: Role | None = None, **kwargs: Any
 
195
  )
196
  )
197
  except Exception:
198
+ raise ValueError("Could not convert prompt result to message.")
 
 
199
 
200
  return messages
201
  except Exception as e:
202
+ logger.exception(f"Error rendering prompt {self.name}: {e}")
203
+ raise ValueError(f"Error rendering prompt {self.name}.")
204
 
205
  def __eq__(self, other: object) -> bool:
206
  if not isinstance(other, Prompt):
src/fastmcp/resources/resource_manager.py CHANGED
@@ -6,7 +6,7 @@ from typing import Any
6
 
7
  from pydantic import AnyUrl
8
 
9
- from fastmcp.exceptions import NotFoundError
10
  from fastmcp.resources import FunctionResource
11
  from fastmcp.resources.resource import Resource
12
  from fastmcp.resources.template import (
@@ -249,6 +249,23 @@ class ResourceManager:
249
 
250
  raise NotFoundError(f"Unknown resource: {uri_str}")
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  def get_resources(self) -> dict[str, Resource]:
253
  """Get all registered resources, keyed by URI."""
254
  return self._resources
 
6
 
7
  from pydantic import AnyUrl
8
 
9
+ from fastmcp.exceptions import NotFoundError, ResourceError
10
  from fastmcp.resources import FunctionResource
11
  from fastmcp.resources.resource import Resource
12
  from fastmcp.resources.template import (
 
249
 
250
  raise NotFoundError(f"Unknown resource: {uri_str}")
251
 
252
+ async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
253
+ """Read a resource contents."""
254
+ resource = await self.get_resource(uri)
255
+
256
+ try:
257
+ return await resource.read()
258
+
259
+ # raise ResourceErrors as-is
260
+ except ResourceError as e:
261
+ logger.error(f"Error reading resource {uri!r}: {e}")
262
+ raise e
263
+
264
+ # raise other exceptions as ResourceErrors without revealing internal details
265
+ except Exception as e:
266
+ logger.error(f"Error reading resource {uri!r}: {e}")
267
+ raise ResourceError(f"Error reading resource {uri!r}") from e
268
+
269
  def get_resources(self) -> dict[str, Resource]:
270
  """Get all registered resources, keyed by URI."""
271
  return self._resources
src/fastmcp/resources/types.py CHANGED
@@ -6,7 +6,7 @@ import inspect
6
  import json
7
  from collections.abc import Callable
8
  from pathlib import Path
9
- from typing import TYPE_CHECKING, Any
10
 
11
  import anyio
12
  import anyio.to_thread
@@ -15,12 +15,13 @@ import pydantic.json
15
  import pydantic_core
16
  from pydantic import Field, ValidationInfo
17
 
 
18
  from fastmcp.resources.resource import Resource
19
  from fastmcp.server.dependencies import get_context
 
20
  from fastmcp.utilities.types import find_kwarg_by_type
21
 
22
- if TYPE_CHECKING:
23
- pass
24
 
25
 
26
  class TextResource(Resource):
@@ -80,8 +81,12 @@ class FunctionResource(Resource):
80
  return result
81
  else:
82
  return pydantic_core.to_json(result, fallback=str, indent=2).decode()
 
 
 
83
  except Exception as e:
84
- raise ValueError(f"Error reading resource {self.uri}: {e}")
 
85
 
86
 
87
  class FileResource(Resource):
@@ -124,7 +129,7 @@ class FileResource(Resource):
124
  return await anyio.to_thread.run_sync(self.path.read_bytes)
125
  return await anyio.to_thread.run_sync(self.path.read_text)
126
  except Exception as e:
127
- raise ValueError(f"Error reading file {self.path}: {e}")
128
 
129
 
130
  class HttpResource(Resource):
@@ -185,7 +190,7 @@ class DirectoryResource(Resource):
185
  else list(self.path.rglob("*"))
186
  )
187
  except Exception as e:
188
- raise ValueError(f"Error listing directory {self.path}: {e}")
189
 
190
  async def read(self) -> str: # Always returns JSON string
191
  """Read the directory listing."""
@@ -193,5 +198,5 @@ class DirectoryResource(Resource):
193
  files = await anyio.to_thread.run_sync(self.list_files)
194
  file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
195
  return json.dumps({"files": file_list}, indent=2)
196
- except Exception as e:
197
- raise ValueError(f"Error reading directory {self.path}: {e}")
 
6
  import json
7
  from collections.abc import Callable
8
  from pathlib import Path
9
+ from typing import Any
10
 
11
  import anyio
12
  import anyio.to_thread
 
15
  import pydantic_core
16
  from pydantic import Field, ValidationInfo
17
 
18
+ from fastmcp.exceptions import ResourceError
19
  from fastmcp.resources.resource import Resource
20
  from fastmcp.server.dependencies import get_context
21
+ from fastmcp.utilities.logging import get_logger
22
  from fastmcp.utilities.types import find_kwarg_by_type
23
 
24
+ logger = get_logger(__name__)
 
25
 
26
 
27
  class TextResource(Resource):
 
81
  return result
82
  else:
83
  return pydantic_core.to_json(result, fallback=str, indent=2).decode()
84
+ except ResourceError as e:
85
+ logger.exception(f"Error reading resource {self.uri}: {e}")
86
+ raise e
87
  except Exception as e:
88
+ logger.exception(f"Error reading resource {self.uri}: {e}")
89
+ raise ValueError(f"Error reading resource {self.uri}.") from e
90
 
91
 
92
  class FileResource(Resource):
 
129
  return await anyio.to_thread.run_sync(self.path.read_bytes)
130
  return await anyio.to_thread.run_sync(self.path.read_text)
131
  except Exception as e:
132
+ raise ResourceError(f"Error reading file {self.path}") from e
133
 
134
 
135
  class HttpResource(Resource):
 
190
  else list(self.path.rglob("*"))
191
  )
192
  except Exception as e:
193
+ raise ResourceError(f"Error listing directory {self.path}: {e}")
194
 
195
  async def read(self) -> str: # Always returns JSON string
196
  """Read the directory listing."""
 
198
  files = await anyio.to_thread.run_sync(self.list_files)
199
  file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
200
  return json.dumps({"files": file_list}, indent=2)
201
+ except Exception:
202
+ raise ResourceError(f"Error reading directory {self.path}")
src/fastmcp/server/openapi.py CHANGED
@@ -14,6 +14,7 @@ import httpx
14
  from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
15
  from pydantic.networks import AnyUrl
16
 
 
17
  from fastmcp.resources import Resource, ResourceTemplate
18
  from fastmcp.server.server import FastMCP
19
  from fastmcp.tools.tool import Tool, _convert_to_content
@@ -163,7 +164,7 @@ class OpenAPITool(Tool):
163
  }
164
  missing_params = required_path_params - path_params.keys()
165
  if missing_params:
166
- raise ValueError(f"Missing required path parameters: {missing_params}")
167
 
168
  for param_name, param_value in path_params.items():
169
  path = path.replace(f"{{{param_name}}}", str(param_value))
 
14
  from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
15
  from pydantic.networks import AnyUrl
16
 
17
+ from fastmcp.exceptions import ToolError
18
  from fastmcp.resources import Resource, ResourceTemplate
19
  from fastmcp.server.server import FastMCP
20
  from fastmcp.tools.tool import Tool, _convert_to_content
 
164
  }
165
  missing_params = required_path_params - path_params.keys()
166
  if missing_params:
167
+ raise ToolError(f"Missing required path parameters: {missing_params}")
168
 
169
  for param_name, param_value in path_params.items():
170
  path = path.replace(f"{{{param_name}}}", str(param_value))
src/fastmcp/server/proxy.py CHANGED
@@ -18,7 +18,7 @@ from mcp.types import (
18
  from pydantic.networks import AnyUrl
19
 
20
  from fastmcp.client import Client
21
- from fastmcp.exceptions import NotFoundError
22
  from fastmcp.prompts import Prompt, PromptMessage
23
  from fastmcp.resources import Resource, ResourceTemplate
24
  from fastmcp.server.context import Context
@@ -64,7 +64,7 @@ class ProxyTool(Tool):
64
  arguments=arguments,
65
  )
66
  if result.isError:
67
- raise ValueError(cast(mcp.types.TextContent, result.content[0]).text)
68
  return result.content
69
 
70
 
@@ -97,7 +97,7 @@ class ProxyResource(Resource):
97
  elif isinstance(result[0], BlobResourceContents):
98
  return result[0].blob
99
  else:
100
- raise ValueError(f"Unsupported content type: {type(result[0])}")
101
 
102
 
103
  class ProxyTemplate(ResourceTemplate):
@@ -138,7 +138,7 @@ class ProxyTemplate(ResourceTemplate):
138
  elif isinstance(result[0], BlobResourceContents):
139
  value = result[0].blob
140
  else:
141
- raise ValueError(f"Unsupported content type: {type(result[0])}")
142
 
143
  return ProxyResource(
144
  client=self._client,
 
18
  from pydantic.networks import AnyUrl
19
 
20
  from fastmcp.client import Client
21
+ from fastmcp.exceptions import NotFoundError, ResourceError, ToolError
22
  from fastmcp.prompts import Prompt, PromptMessage
23
  from fastmcp.resources import Resource, ResourceTemplate
24
  from fastmcp.server.context import Context
 
64
  arguments=arguments,
65
  )
66
  if result.isError:
67
+ raise ToolError(cast(mcp.types.TextContent, result.content[0]).text)
68
  return result.content
69
 
70
 
 
97
  elif isinstance(result[0], BlobResourceContents):
98
  return result[0].blob
99
  else:
100
+ raise ResourceError(f"Unsupported content type: {type(result[0])}")
101
 
102
 
103
  class ProxyTemplate(ResourceTemplate):
 
138
  elif isinstance(result[0], BlobResourceContents):
139
  value = result[0].blob
140
  else:
141
+ raise ResourceError(f"Unsupported content type: {type(result[0])}")
142
 
143
  return ProxyResource(
144
  client=self._client,
src/fastmcp/server/server.py CHANGED
@@ -43,7 +43,7 @@ from starlette.routing import BaseRoute, Route
43
 
44
  import fastmcp.server
45
  import fastmcp.settings
46
- from fastmcp.exceptions import NotFoundError, ResourceError
47
  from fastmcp.prompts import Prompt, PromptManager
48
  from fastmcp.prompts.prompt import PromptResult
49
  from fastmcp.resources import Resource, ResourceManager
@@ -385,16 +385,13 @@ class FastMCP(Generic[LifespanResultT]):
385
  with fastmcp.server.context.Context(fastmcp=self):
386
  if self._resource_manager.has_resource(uri):
387
  resource = await self._resource_manager.get_resource(uri)
388
- try:
389
- content = await resource.read()
390
- return [
391
- ReadResourceContents(
392
- content=content, mime_type=resource.mime_type
393
- )
394
- ]
395
- except Exception as e:
396
- logger.error(f"Error reading resource {uri}: {e}")
397
- raise ResourceError(str(e))
398
  else:
399
  for server in self._mounted_servers.values():
400
  if server.match_resource(str(uri)):
 
43
 
44
  import fastmcp.server
45
  import fastmcp.settings
46
+ from fastmcp.exceptions import NotFoundError
47
  from fastmcp.prompts import Prompt, PromptManager
48
  from fastmcp.prompts.prompt import PromptResult
49
  from fastmcp.resources import Resource, ResourceManager
 
385
  with fastmcp.server.context.Context(fastmcp=self):
386
  if self._resource_manager.has_resource(uri):
387
  resource = await self._resource_manager.get_resource(uri)
388
+ content = await self._resource_manager.read_resource(uri)
389
+ return [
390
+ ReadResourceContents(
391
+ content=content,
392
+ mime_type=resource.mime_type,
393
+ )
394
+ ]
 
 
 
395
  else:
396
  for server in self._mounted_servers.values():
397
  if server.match_resource(str(uri)):
src/fastmcp/tools/tool.py CHANGED
@@ -11,7 +11,6 @@ from mcp.types import Tool as MCPTool
11
  from pydantic import BaseModel, BeforeValidator, Field
12
 
13
  import fastmcp
14
- from fastmcp.exceptions import ToolError
15
  from fastmcp.server.dependencies import get_context
16
  from fastmcp.utilities.json_schema import prune_params
17
  from fastmcp.utilities.logging import get_logger
@@ -102,49 +101,45 @@ class Tool(BaseModel):
102
 
103
  arguments = arguments.copy()
104
 
105
- try:
106
- context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
107
- if context_kwarg and context_kwarg not in arguments:
108
- arguments[context_kwarg] = get_context()
109
-
110
- if fastmcp.settings.settings.tool_attempt_parse_json_args:
111
- # Pre-parse data from JSON in order to handle cases like `["a", "b", "c"]`
112
- # being passed in as JSON inside a string rather than an actual list.
113
- #
114
- # Claude desktop is prone to this - in fact it seems incapable of NOT doing
115
- # this. For sub-models, it tends to pass dicts (JSON objects) as JSON strings,
116
- # which can be pre-parsed here.
117
- signature = inspect.signature(self.fn)
118
- for param_name in self.parameters["properties"]:
119
- arg = arguments.get(param_name, None)
120
- # if not in signature, we won't have annotations, so skip logic
121
- if param_name not in signature.parameters:
122
- continue
123
- # if not a string, we won't have a JSON to parse, so skip logic
124
- if not isinstance(arg, str):
125
- continue
126
- # skip if the type is a simple type (int, float, bool)
127
- if signature.parameters[param_name].annotation in (
128
- int,
129
- float,
130
- bool,
131
- ):
132
- continue
133
- try:
134
- arguments[param_name] = json.loads(arg)
135
-
136
- except json.JSONDecodeError:
137
- pass
138
-
139
- type_adapter = get_cached_typeadapter(self.fn)
140
- result = type_adapter.validate_python(arguments)
141
- if inspect.isawaitable(result):
142
- result = await result
143
-
144
- return _convert_to_content(result, serializer=self.serializer)
145
- except Exception as e:
146
- logger.exception(f"Tool {self.name} failed")
147
- raise ToolError(f"Error executing tool {self.name}: {e}") from e
148
 
149
  def to_mcp_tool(self, **overrides: Any) -> MCPTool:
150
  kwargs = {
 
11
  from pydantic import BaseModel, BeforeValidator, Field
12
 
13
  import fastmcp
 
14
  from fastmcp.server.dependencies import get_context
15
  from fastmcp.utilities.json_schema import prune_params
16
  from fastmcp.utilities.logging import get_logger
 
101
 
102
  arguments = arguments.copy()
103
 
104
+ context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
105
+ if context_kwarg and context_kwarg not in arguments:
106
+ arguments[context_kwarg] = get_context()
107
+
108
+ if fastmcp.settings.settings.tool_attempt_parse_json_args:
109
+ # Pre-parse data from JSON in order to handle cases like `["a", "b", "c"]`
110
+ # being passed in as JSON inside a string rather than an actual list.
111
+ #
112
+ # Claude desktop is prone to this - in fact it seems incapable of NOT doing
113
+ # this. For sub-models, it tends to pass dicts (JSON objects) as JSON strings,
114
+ # which can be pre-parsed here.
115
+ signature = inspect.signature(self.fn)
116
+ for param_name in self.parameters["properties"]:
117
+ arg = arguments.get(param_name, None)
118
+ # if not in signature, we won't have annotations, so skip logic
119
+ if param_name not in signature.parameters:
120
+ continue
121
+ # if not a string, we won't have a JSON to parse, so skip logic
122
+ if not isinstance(arg, str):
123
+ continue
124
+ # skip if the type is a simple type (int, float, bool)
125
+ if signature.parameters[param_name].annotation in (
126
+ int,
127
+ float,
128
+ bool,
129
+ ):
130
+ continue
131
+ try:
132
+ arguments[param_name] = json.loads(arg)
133
+
134
+ except json.JSONDecodeError:
135
+ pass
136
+
137
+ type_adapter = get_cached_typeadapter(self.fn)
138
+ result = type_adapter.validate_python(arguments)
139
+ if inspect.isawaitable(result):
140
+ result = await result
141
+
142
+ return _convert_to_content(result, serializer=self.serializer)
 
 
 
 
143
 
144
  def to_mcp_tool(self, **overrides: Any) -> MCPTool:
145
  kwargs = {
src/fastmcp/tools/tool_manager.py CHANGED
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any
5
 
6
  from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
7
 
8
- from fastmcp.exceptions import NotFoundError
9
  from fastmcp.settings import DuplicateBehavior
10
  from fastmcp.tools.tool import Tool
11
  from fastmcp.utilities.logging import get_logger
@@ -102,4 +102,15 @@ class ToolManager:
102
  if not tool:
103
  raise NotFoundError(f"Unknown tool: {key}")
104
 
105
- return await tool.run(arguments)
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotations
7
 
8
+ from fastmcp.exceptions import NotFoundError, ToolError
9
  from fastmcp.settings import DuplicateBehavior
10
  from fastmcp.tools.tool import Tool
11
  from fastmcp.utilities.logging import get_logger
 
102
  if not tool:
103
  raise NotFoundError(f"Unknown tool: {key}")
104
 
105
+ try:
106
+ return await tool.run(arguments)
107
+
108
+ # raise ToolErrors as-is
109
+ except ToolError as e:
110
+ logger.exception(f"Error calling tool {key!r}: {e}")
111
+ raise e
112
+
113
+ # raise other exceptions as ToolErrors without revealing internal details
114
+ except Exception as e:
115
+ logger.exception(f"Error calling tool {key!r}: {e}")
116
+ raise ToolError(f"Error calling tool {key!r}") from e
tests/client/test_client.py CHANGED
@@ -5,6 +5,7 @@ from pydantic import AnyUrl
5
 
6
  from fastmcp.client import Client
7
  from fastmcp.client.transports import FastMCPTransport
 
8
  from fastmcp.prompts.prompt import TextContent
9
  from fastmcp.server.server import FastMCP
10
 
@@ -404,3 +405,67 @@ async def test_tagged_template_functionality(tagged_resources_server):
404
  content_str = str(result[0])
405
  assert '"id": "123"' in content_str
406
  assert '"type": "template_data"' in content_str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  from fastmcp.client import Client
7
  from fastmcp.client.transports import FastMCPTransport
8
+ from fastmcp.exceptions import ResourceError, ToolError
9
  from fastmcp.prompts.prompt import TextContent
10
  from fastmcp.server.server import FastMCP
11
 
 
405
  content_str = str(result[0])
406
  assert '"id": "123"' in content_str
407
  assert '"type": "template_data"' in content_str
408
+
409
+
410
+ class TestErrorHandling:
411
+ async def test_general_tool_exceptions_are_masked(self):
412
+ mcp = FastMCP("TestServer")
413
+
414
+ @mcp.tool()
415
+ def error_tool():
416
+ raise ValueError("This is a test error (abc)")
417
+
418
+ client = Client(transport=FastMCPTransport(mcp))
419
+
420
+ async with client:
421
+ result = await client.call_tool_mcp("error_tool", {})
422
+ assert result.isError
423
+ assert isinstance(result.content[0], TextContent)
424
+ assert "test error" not in result.content[0].text
425
+ assert "abc" not in result.content[0].text
426
+
427
+ async def test_specific_tool_errors_are_sent_to_client(self):
428
+ mcp = FastMCP("TestServer")
429
+
430
+ @mcp.tool()
431
+ def custom_error_tool():
432
+ raise ToolError("This is a test error (abc)")
433
+
434
+ client = Client(transport=FastMCPTransport(mcp))
435
+
436
+ async with client:
437
+ result = await client.call_tool_mcp("custom_error_tool", {})
438
+ assert result.isError
439
+ assert isinstance(result.content[0], TextContent)
440
+ assert "test error" in result.content[0].text
441
+ assert "abc" in result.content[0].text
442
+
443
+ async def test_general_resource_exceptions_are_masked(self):
444
+ mcp = FastMCP("TestServer")
445
+
446
+ @mcp.resource(uri="exception://resource")
447
+ async def exception_resource():
448
+ raise ValueError("This is an internal error (sensitive)")
449
+
450
+ client = Client(transport=FastMCPTransport(mcp))
451
+
452
+ async with client:
453
+ with pytest.raises(Exception) as excinfo:
454
+ await client.read_resource(AnyUrl("exception://resource"))
455
+ assert "Error reading resource" in str(excinfo.value)
456
+ assert "sensitive" not in str(excinfo.value)
457
+ assert "internal error" not in str(excinfo.value)
458
+
459
+ async def test_resource_errors_are_sent_to_client(self):
460
+ mcp = FastMCP("TestServer")
461
+
462
+ @mcp.resource(uri="error://resource")
463
+ async def error_resource():
464
+ raise ResourceError("This is a resource error (xyz)")
465
+
466
+ client = Client(transport=FastMCPTransport(mcp))
467
+
468
+ async with client:
469
+ with pytest.raises(Exception) as excinfo:
470
+ await client.read_resource(AnyUrl("error://resource"))
471
+ assert "This is a resource error (xyz)" in str(excinfo.value)
tests/contrib/test_bulk_tool_caller.py CHANGED
@@ -27,8 +27,7 @@ async def error_tool(arg1: str) -> dict[str, Any]:
27
  def error_tool_result_factory(arg1: str) -> CallToolRequestResult:
28
  """Generates the expected error result for error_tool."""
29
  # Mimic the error message format generated by BulkToolCaller when catching ToolException
30
- exception_message = f"Error in tool with arg1: {arg1}"
31
- formatted_error_text = f"Error executing tool error_tool: {exception_message}"
32
  return CallToolRequestResult(
33
  isError=True,
34
  content=[TextContent(text=formatted_error_text, type="text")],
 
27
  def error_tool_result_factory(arg1: str) -> CallToolRequestResult:
28
  """Generates the expected error result for error_tool."""
29
  # Mimic the error message format generated by BulkToolCaller when catching ToolException
30
+ formatted_error_text = "Error calling tool 'error_tool'"
 
31
  return CallToolRequestResult(
32
  isError=True,
33
  content=[TextContent(text=formatted_error_text, type="text")],
tests/resources/test_file_resources.py CHANGED
@@ -5,6 +5,7 @@ from tempfile import NamedTemporaryFile
5
  import pytest
6
  from pydantic import FileUrl
7
 
 
8
  from fastmcp.resources import FileResource
9
 
10
 
@@ -94,7 +95,7 @@ class TestFileResource:
94
  name="test",
95
  path=missing,
96
  )
97
- with pytest.raises(ValueError, match="Error reading file"):
98
  await resource.read()
99
 
100
  @pytest.mark.skipif(
@@ -109,7 +110,7 @@ class TestFileResource:
109
  name="test",
110
  path=temp_file,
111
  )
112
- with pytest.raises(ValueError, match="Error reading file"):
113
  await resource.read()
114
  finally:
115
  temp_file.chmod(0o644) # Restore permissions
 
5
  import pytest
6
  from pydantic import FileUrl
7
 
8
+ from fastmcp.exceptions import ResourceError
9
  from fastmcp.resources import FileResource
10
 
11
 
 
95
  name="test",
96
  path=missing,
97
  )
98
+ with pytest.raises(ResourceError, match="Error reading file"):
99
  await resource.read()
100
 
101
  @pytest.mark.skipif(
 
110
  name="test",
111
  path=temp_file,
112
  )
113
+ with pytest.raises(ResourceError, match="Error reading file"):
114
  await resource.read()
115
  finally:
116
  temp_file.chmod(0o644) # Restore permissions
tests/resources/test_resource_manager.py CHANGED
@@ -4,7 +4,7 @@ from tempfile import NamedTemporaryFile
4
  import pytest
5
  from pydantic import AnyUrl, FileUrl
6
 
7
- from fastmcp.exceptions import NotFoundError
8
  from fastmcp.resources import (
9
  FileResource,
10
  FunctionResource,
@@ -540,3 +540,113 @@ class TestCustomResourceKeys:
540
  # Shouldn't work with the original template pattern
541
  with pytest.raises(NotFoundError, match="Unknown resource"):
542
  await manager.get_resource("greet://world")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import pytest
5
  from pydantic import AnyUrl, FileUrl
6
 
7
+ from fastmcp.exceptions import NotFoundError, ResourceError
8
  from fastmcp.resources import (
9
  FileResource,
10
  FunctionResource,
 
540
  # Shouldn't work with the original template pattern
541
  with pytest.raises(NotFoundError, match="Unknown resource"):
542
  await manager.get_resource("greet://world")
543
+
544
+
545
+ class TestResourceErrorHandling:
546
+ """Test error handling in the ResourceManager."""
547
+
548
+ async def test_resource_error_passthrough(self):
549
+ """Test that ResourceErrors are passed through directly."""
550
+ manager = ResourceManager()
551
+
552
+ async def error_resource():
553
+ """Resource that raises a ResourceError."""
554
+ raise ResourceError("Specific resource error")
555
+
556
+ resource = FunctionResource(
557
+ uri=AnyUrl("error://resource"),
558
+ name="error_resource",
559
+ fn=error_resource,
560
+ )
561
+ manager.add_resource(resource)
562
+
563
+ with pytest.raises(ResourceError, match="Specific resource error"):
564
+ await manager.read_resource("error://resource")
565
+
566
+ async def test_exception_converted_to_resource_error(self):
567
+ """Test that other exceptions are converted to ResourceError."""
568
+ manager = ResourceManager()
569
+
570
+ async def buggy_resource():
571
+ """Resource that raises a ValueError."""
572
+ raise ValueError("Internal error details")
573
+
574
+ resource = FunctionResource(
575
+ uri=AnyUrl("buggy://resource"),
576
+ name="buggy_resource",
577
+ fn=buggy_resource,
578
+ )
579
+ manager.add_resource(resource)
580
+
581
+ with pytest.raises(ResourceError) as excinfo:
582
+ await manager.read_resource("buggy://resource")
583
+
584
+ # Exception message should contain the resource URI but not the internal details
585
+ assert "Error reading resource 'buggy://resource'" in str(excinfo.value)
586
+ assert "Internal error details" not in str(excinfo.value)
587
+
588
+ async def test_template_resource_error_passthrough(self):
589
+ """Test that ResourceErrors from template-generated resources are passed through."""
590
+ manager = ResourceManager()
591
+
592
+ def error_template(param: str):
593
+ """Template that raises a ResourceError."""
594
+ raise ResourceError(f"Template error with param {param}")
595
+
596
+ template = ResourceTemplate.from_function(
597
+ fn=error_template,
598
+ uri_template="error://{param}",
599
+ name="error_template",
600
+ )
601
+ manager.add_template(template)
602
+
603
+ # ResourceErrors in templates are wrapped in ValueError
604
+ with pytest.raises(ValueError) as excinfo:
605
+ await manager.read_resource("error://test")
606
+
607
+ # The original error message should be included in the ValueError
608
+ assert "Template error with param test" in str(excinfo.value)
609
+
610
+ async def test_template_exception_converted_to_resource_error(self):
611
+ """Test that other exceptions from template-generated resources are converted."""
612
+ manager = ResourceManager()
613
+
614
+ def buggy_template(param: str):
615
+ """Template that raises a ValueError."""
616
+ raise ValueError(f"Internal template error with {param}")
617
+
618
+ template = ResourceTemplate.from_function(
619
+ fn=buggy_template,
620
+ uri_template="buggy://{param}",
621
+ name="buggy_template",
622
+ )
623
+ manager.add_template(template)
624
+
625
+ # First, the template creation will fail with ValueError
626
+ with pytest.raises(ValueError):
627
+ await manager.read_resource("buggy://test")
628
+
629
+ # Let's test with a template that returns a resource that fails
630
+ def create_failing_resource(param: str):
631
+ async def failing_resource():
632
+ raise ValueError(f"Resource from template fails with {param}")
633
+
634
+ return FunctionResource(
635
+ uri=AnyUrl(f"failing://{param}"),
636
+ name=f"failing_{param}",
637
+ fn=failing_resource,
638
+ )
639
+
640
+ template = ResourceTemplate.from_function(
641
+ fn=create_failing_resource,
642
+ uri_template="failing://{param}",
643
+ name="failing_template",
644
+ )
645
+ manager.add_template(template)
646
+
647
+ with pytest.raises(ResourceError) as excinfo:
648
+ await manager.read_resource("failing://test")
649
+
650
+ # Exception should contain resource URI but not internal details
651
+ assert "Error reading resource 'failing://test'" in str(excinfo.value)
652
+ assert "Resource from template fails with test" not in str(excinfo.value)
tests/server/test_server_interactions.py CHANGED
@@ -17,6 +17,7 @@ from mcp.types import (
17
  from pydantic import AnyUrl, Field
18
 
19
  from fastmcp import Client, Context, FastMCP
 
20
  from fastmcp.exceptions import ClientError
21
  from fastmcp.prompts.prompt import EmbeddedResource, PromptMessage
22
  from fastmcp.resources import FileResource, FunctionResource
@@ -94,12 +95,19 @@ class TestTools:
94
  with pytest.raises(Exception):
95
  await client.call_tool("error_tool", {})
96
 
97
- async def test_call_tool_error_as_client_raw(self, tool_server: FastMCP):
98
- async with Client(tool_server) as client:
99
- result = await client.call_tool_mcp("error_tool", {})
100
- assert result.isError
101
- assert isinstance(result.content[0], TextContent)
102
- assert "Test error" in result.content[0].text
 
 
 
 
 
 
 
103
 
104
  async def test_tool_returns_list(self, tool_server: FastMCP):
105
  async with Client(tool_server) as client:
@@ -313,7 +321,7 @@ class TestToolParameters:
313
  async with Client(mcp) as client:
314
  with pytest.raises(
315
  ClientError,
316
- match="Input should be a valid integer, unable to parse string as an integer",
317
  ):
318
  await client.call_tool("my_tool", {"x": "not an int"})
319
 
@@ -357,10 +365,7 @@ class TestToolParameters:
357
  pass
358
 
359
  async with Client(mcp) as client:
360
- with pytest.raises(
361
- ClientError,
362
- match="Input should be greater than or equal to 1",
363
- ):
364
  await client.call_tool("analyze", {"x": 0})
365
 
366
  async def test_default_field_validation(self):
@@ -371,10 +376,7 @@ class TestToolParameters:
371
  pass
372
 
373
  async with Client(mcp) as client:
374
- with pytest.raises(
375
- ClientError,
376
- match="Input should be greater than or equal to 1",
377
- ):
378
  await client.call_tool("analyze", {"x": 0})
379
 
380
  async def test_default_field_is_still_required_if_no_default_specified(self):
@@ -385,7 +387,7 @@ class TestToolParameters:
385
  pass
386
 
387
  async with Client(mcp) as client:
388
- with pytest.raises(ClientError, match="Missing required argument"):
389
  await client.call_tool("analyze", {})
390
 
391
  async def test_literal_type_validation_error(self):
@@ -396,7 +398,7 @@ class TestToolParameters:
396
  pass
397
 
398
  async with Client(mcp) as client:
399
- with pytest.raises(ClientError, match="Input should be 'a' or 'b'"):
400
  await client.call_tool("analyze", {"x": "c"})
401
 
402
  async def test_literal_type_validation_success(self):
@@ -424,9 +426,7 @@ class TestToolParameters:
424
  return x.value
425
 
426
  async with Client(mcp) as client:
427
- with pytest.raises(
428
- ClientError, match="Input should be 'red', 'green' or 'blue'"
429
- ):
430
  await client.call_tool("analyze", {"x": "some-color"})
431
 
432
  async def test_enum_type_validation_success(self):
@@ -462,7 +462,7 @@ class TestToolParameters:
462
  assert isinstance(result[0], TextContent)
463
  assert result[0].text == "1.0"
464
 
465
- with pytest.raises(ClientError, match="2 validation errors"):
466
  await client.call_tool("analyze", {"x": "not a number"})
467
 
468
  async def test_path_type(self):
@@ -489,7 +489,7 @@ class TestToolParameters:
489
  return str(path)
490
 
491
  async with Client(mcp) as client:
492
- with pytest.raises(ClientError, match="Input is not a valid path"):
493
  await client.call_tool("send_path", {"path": 1})
494
 
495
  async def test_uuid_type(self):
@@ -515,7 +515,7 @@ class TestToolParameters:
515
  return str(x)
516
 
517
  async with Client(mcp) as client:
518
- with pytest.raises(ClientError, match="Input should be a valid UUID"):
519
  await client.call_tool("send_uuid", {"x": "not a uuid"})
520
 
521
  async def test_datetime_type(self):
@@ -554,7 +554,7 @@ class TestToolParameters:
554
  return x.isoformat()
555
 
556
  async with Client(mcp) as client:
557
- with pytest.raises(ClientError, match="Input should be a valid datetime"):
558
  await client.call_tool("send_datetime", {"x": "not a datetime"})
559
 
560
  async def test_date_type(self):
 
17
  from pydantic import AnyUrl, Field
18
 
19
  from fastmcp import Client, Context, FastMCP
20
+ from fastmcp.client.transports import FastMCPTransport
21
  from fastmcp.exceptions import ClientError
22
  from fastmcp.prompts.prompt import EmbeddedResource, PromptMessage
23
  from fastmcp.resources import FileResource, FunctionResource
 
95
  with pytest.raises(Exception):
96
  await client.call_tool("error_tool", {})
97
 
98
+ async def test_call_tool_error_as_client_raw(self):
99
+ """Test raising and catching errors from a tool."""
100
+ mcp = FastMCP()
101
+ client = Client(transport=FastMCPTransport(mcp))
102
+
103
+ @mcp.tool()
104
+ def error_tool():
105
+ raise ValueError("Test error")
106
+
107
+ async with client:
108
+ with pytest.raises(Exception) as excinfo:
109
+ await client.call_tool("error_tool", {})
110
+ assert "Error calling tool 'error_tool'" in str(excinfo.value)
111
 
112
  async def test_tool_returns_list(self, tool_server: FastMCP):
113
  async with Client(tool_server) as client:
 
321
  async with Client(mcp) as client:
322
  with pytest.raises(
323
  ClientError,
324
+ match="Error calling tool 'my_tool'",
325
  ):
326
  await client.call_tool("my_tool", {"x": "not an int"})
327
 
 
365
  pass
366
 
367
  async with Client(mcp) as client:
368
+ with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
 
 
 
369
  await client.call_tool("analyze", {"x": 0})
370
 
371
  async def test_default_field_validation(self):
 
376
  pass
377
 
378
  async with Client(mcp) as client:
379
+ with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
 
 
 
380
  await client.call_tool("analyze", {"x": 0})
381
 
382
  async def test_default_field_is_still_required_if_no_default_specified(self):
 
387
  pass
388
 
389
  async with Client(mcp) as client:
390
+ with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
391
  await client.call_tool("analyze", {})
392
 
393
  async def test_literal_type_validation_error(self):
 
398
  pass
399
 
400
  async with Client(mcp) as client:
401
+ with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
402
  await client.call_tool("analyze", {"x": "c"})
403
 
404
  async def test_literal_type_validation_success(self):
 
426
  return x.value
427
 
428
  async with Client(mcp) as client:
429
+ with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
 
 
430
  await client.call_tool("analyze", {"x": "some-color"})
431
 
432
  async def test_enum_type_validation_success(self):
 
462
  assert isinstance(result[0], TextContent)
463
  assert result[0].text == "1.0"
464
 
465
+ with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
466
  await client.call_tool("analyze", {"x": "not a number"})
467
 
468
  async def test_path_type(self):
 
489
  return str(path)
490
 
491
  async with Client(mcp) as client:
492
+ with pytest.raises(ClientError, match="Error calling tool 'send_path'"):
493
  await client.call_tool("send_path", {"path": 1})
494
 
495
  async def test_uuid_type(self):
 
515
  return str(x)
516
 
517
  async with Client(mcp) as client:
518
+ with pytest.raises(ClientError, match="Error calling tool 'send_uuid'"):
519
  await client.call_tool("send_uuid", {"x": "not a uuid"})
520
 
521
  async def test_datetime_type(self):
 
554
  return x.isoformat()
555
 
556
  async with Client(mcp) as client:
557
+ with pytest.raises(ClientError, match="Error calling tool 'send_datetime'"):
558
  await client.call_tool("send_datetime", {"x": "not a datetime"})
559
 
560
  async def test_date_type(self):
tests/tools/test_tool.py CHANGED
@@ -300,7 +300,7 @@ class TestLegacyToolJsonParsing:
300
  async with Client(mcp) as client:
301
  with pytest.raises(
302
  ClientError,
303
- match="Input should be a valid list",
304
  ):
305
  await client.call_tool("process_list", {"items": "['a', 'b', 3]"})
306
 
 
300
  async with Client(mcp) as client:
301
  with pytest.raises(
302
  ClientError,
303
+ match="Error calling tool 'process_list'",
304
  ):
305
  await client.call_tool("process_list", {"items": "['a', 'b', 3]"})
306
 
tests/tools/test_tool_manager.py CHANGED
@@ -643,7 +643,7 @@ class TestContextHandling:
643
 
644
  with context:
645
  with pytest.raises(
646
- ToolError, match="Error executing tool tool_with_context"
647
  ):
648
  await manager.call_tool("tool_with_context", {"x": 42})
649
 
@@ -740,3 +740,67 @@ class TestCustomToolNames:
740
 
741
  # But the function is different
742
  assert stored_tool.fn.__name__ == "replacement_fn"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
643
 
644
  with context:
645
  with pytest.raises(
646
+ ToolError, match="Error calling tool 'tool_with_context'"
647
  ):
648
  await manager.call_tool("tool_with_context", {"x": 42})
649
 
 
740
 
741
  # But the function is different
742
  assert stored_tool.fn.__name__ == "replacement_fn"
743
+
744
+
745
+ class TestToolErrorHandling:
746
+ """Test error handling in the ToolManager."""
747
+
748
+ async def test_tool_error_passthrough(self):
749
+ """Test that ToolErrors are passed through directly."""
750
+ manager = ToolManager()
751
+
752
+ def error_tool(x: int) -> int:
753
+ """Tool that raises a ToolError."""
754
+ raise ToolError("Specific tool error")
755
+
756
+ manager.add_tool_from_fn(error_tool)
757
+
758
+ with pytest.raises(ToolError, match="Specific tool error"):
759
+ await manager.call_tool("error_tool", {"x": 42})
760
+
761
+ async def test_exception_converted_to_tool_error(self):
762
+ """Test that other exceptions are converted to ToolError."""
763
+ manager = ToolManager()
764
+
765
+ def buggy_tool(x: int) -> int:
766
+ """Tool that raises a ValueError."""
767
+ raise ValueError("Internal error details")
768
+
769
+ manager.add_tool_from_fn(buggy_tool)
770
+
771
+ with pytest.raises(ToolError) as excinfo:
772
+ await manager.call_tool("buggy_tool", {"x": 42})
773
+
774
+ # Exception message should contain the tool name but not the internal details
775
+ assert "Error calling tool 'buggy_tool'" in str(excinfo.value)
776
+ assert "Internal error details" not in str(excinfo.value)
777
+
778
+ async def test_async_tool_error_passthrough(self):
779
+ """Test that ToolErrors from async tools are passed through directly."""
780
+ manager = ToolManager()
781
+
782
+ async def async_error_tool(x: int) -> int:
783
+ """Async tool that raises a ToolError."""
784
+ raise ToolError("Async tool error")
785
+
786
+ manager.add_tool_from_fn(async_error_tool)
787
+
788
+ with pytest.raises(ToolError, match="Async tool error"):
789
+ await manager.call_tool("async_error_tool", {"x": 42})
790
+
791
+ async def test_async_exception_converted_to_tool_error(self):
792
+ """Test that other exceptions from async tools are converted to ToolError."""
793
+ manager = ToolManager()
794
+
795
+ async def async_buggy_tool(x: int) -> int:
796
+ """Async tool that raises a ValueError."""
797
+ raise ValueError("Internal async error details")
798
+
799
+ manager.add_tool_from_fn(async_buggy_tool)
800
+
801
+ with pytest.raises(ToolError) as excinfo:
802
+ await manager.call_tool("async_buggy_tool", {"x": 42})
803
+
804
+ # Exception message should contain the tool name but not the internal details
805
+ assert "Error calling tool 'async_buggy_tool'" in str(excinfo.value)
806
+ assert "Internal async error details" not in str(excinfo.value)