Jeremiah Lowin commited on
Commit
070f554
·
unverified ·
2 Parent(s): d2d17d2678e6ab

Merge pull request #434 from jlowin/errors

Browse files

Improve error handling for tools and resources

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 (
@@ -244,11 +244,32 @@ class ResourceManager:
244
  uri_str,
245
  params=params,
246
  )
 
 
 
247
  except Exception as e:
 
248
  raise ValueError(f"Error creating resource from template: {e}")
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 (
 
244
  uri_str,
245
  params=params,
246
  )
247
+ except ResourceError as e:
248
+ logger.error(f"Error creating resource from template: {e}")
249
+ raise e
250
  except Exception as e:
251
+ logger.error(f"Error creating resource from template: {e}")
252
  raise ValueError(f"Error creating resource from template: {e}")
253
 
254
  raise NotFoundError(f"Unknown resource: {uri_str}")
255
 
256
+ async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
257
+ """Read a resource contents."""
258
+ resource = await self.get_resource(uri)
259
+
260
+ try:
261
+ return await resource.read()
262
+
263
+ # raise ResourceErrors as-is
264
+ except ResourceError as e:
265
+ logger.error(f"Error reading resource {uri!r}: {e}")
266
+ raise e
267
+
268
+ # raise other exceptions as ResourceErrors without revealing internal details
269
+ except Exception as e:
270
+ logger.error(f"Error reading resource {uri!r}: {e}")
271
+ raise ResourceError(f"Error reading resource {uri!r}") from e
272
+
273
  def get_resources(self) -> dict[str, Resource]:
274
  """Get all registered resources, keyed by URI."""
275
  return self._resources
src/fastmcp/resources/template.py CHANGED
@@ -171,28 +171,27 @@ class ResourceTemplate(BaseModel):
171
  """Create a resource from the template with the given parameters."""
172
  from fastmcp.server.context import Context
173
 
174
- try:
175
- # Add context to parameters if needed
176
- kwargs = params.copy()
177
- context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
178
- if context_kwarg and context_kwarg not in kwargs:
179
- kwargs[context_kwarg] = get_context()
180
 
 
181
  # Call function and check if result is a coroutine
182
  result = self.fn(**kwargs)
183
  if inspect.iscoroutine(result):
184
  result = await result
185
-
186
- return FunctionResource(
187
- uri=AnyUrl(uri), # Explicitly convert to AnyUrl
188
- name=self.name,
189
- description=self.description,
190
- mime_type=self.mime_type,
191
- fn=lambda **kwargs: result, # Capture result in closure
192
- tags=self.tags,
193
- )
194
- except Exception as e:
195
- raise ValueError(f"Error creating resource from template: {e}")
196
 
197
  def __eq__(self, other: object) -> bool:
198
  if not isinstance(other, ResourceTemplate):
 
171
  """Create a resource from the template with the given parameters."""
172
  from fastmcp.server.context import Context
173
 
174
+ # Add context to parameters if needed
175
+ kwargs = params.copy()
176
+ context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
177
+ if context_kwarg and context_kwarg not in kwargs:
178
+ kwargs[context_kwarg] = get_context()
 
179
 
180
+ async def resource_read_fn() -> str | bytes:
181
  # Call function and check if result is a coroutine
182
  result = self.fn(**kwargs)
183
  if inspect.iscoroutine(result):
184
  result = await result
185
+ return result
186
+
187
+ return FunctionResource(
188
+ uri=AnyUrl(uri), # Explicitly convert to AnyUrl
189
+ name=self.name,
190
+ description=self.description,
191
+ mime_type=self.mime_type,
192
+ fn=resource_read_fn,
193
+ tags=self.tags,
194
+ )
 
195
 
196
  def __eq__(self, other: object) -> bool:
197
  if not isinstance(other, ResourceTemplate):
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):
@@ -62,26 +63,23 @@ class FunctionResource(Resource):
62
  """Read the resource by calling the wrapped function."""
63
  from fastmcp.server.context import Context
64
 
65
- try:
66
- kwargs = {}
67
- context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
68
- if context_kwarg is not None:
69
- kwargs[context_kwarg] = get_context()
70
-
71
- result = self.fn(**kwargs)
72
- if inspect.iscoroutinefunction(self.fn):
73
- result = await result
74
-
75
- if isinstance(result, Resource):
76
- return await result.read()
77
- elif isinstance(result, bytes):
78
- return result
79
- elif isinstance(result, str):
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 +122,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 +183,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 +191,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):
 
63
  """Read the resource by calling the wrapped function."""
64
  from fastmcp.server.context import Context
65
 
66
+ kwargs = {}
67
+ context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
68
+ if context_kwarg is not None:
69
+ kwargs[context_kwarg] = get_context()
70
+
71
+ result = self.fn(**kwargs)
72
+ if inspect.iscoroutinefunction(self.fn):
73
+ result = await result
74
+
75
+ if isinstance(result, Resource):
76
+ return await result.read()
77
+ elif isinstance(result, bytes):
78
+ return result
79
+ elif isinstance(result, str):
80
+ return result
81
+ else:
82
+ return pydantic_core.to_json(result, fallback=str, indent=2).decode()
 
 
 
83
 
84
 
85
  class FileResource(Resource):
 
122
  return await anyio.to_thread.run_sync(self.path.read_bytes)
123
  return await anyio.to_thread.run_sync(self.path.read_text)
124
  except Exception as e:
125
+ raise ResourceError(f"Error reading file {self.path}") from e
126
 
127
 
128
  class HttpResource(Resource):
 
183
  else list(self.path.rglob("*"))
184
  )
185
  except Exception as e:
186
+ raise ResourceError(f"Error listing directory {self.path}: {e}")
187
 
188
  async def read(self) -> str: # Always returns JSON string
189
  """Read the directory listing."""
 
191
  files = await anyio.to_thread.run_sync(self.list_files)
192
  file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
193
  return json.dumps({"files": file_list}, indent=2)
194
+ except Exception:
195
+ 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,97 @@ 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)
472
+
473
+ async def test_general_template_exceptions_are_masked(self):
474
+ mcp = FastMCP("TestServer")
475
+
476
+ @mcp.resource(uri="exception://resource/{id}")
477
+ async def exception_resource(id: str):
478
+ raise ValueError("This is an internal error (sensitive)")
479
+
480
+ client = Client(transport=FastMCPTransport(mcp))
481
+
482
+ async with client:
483
+ with pytest.raises(Exception) as excinfo:
484
+ await client.read_resource(AnyUrl("exception://resource/123"))
485
+ assert "Error reading resource" in str(excinfo.value)
486
+ assert "sensitive" not in str(excinfo.value)
487
+ assert "internal error" not in str(excinfo.value)
488
+
489
+ async def test_template_errors_are_sent_to_client(self):
490
+ mcp = FastMCP("TestServer")
491
+
492
+ @mcp.resource(uri="error://resource/{id}")
493
+ async def error_resource(id: str):
494
+ raise ResourceError("This is a resource error (xyz)")
495
+
496
+ client = Client(transport=FastMCPTransport(mcp))
497
+
498
+ async with client:
499
+ with pytest.raises(Exception) as excinfo:
500
+ await client.read_resource(AnyUrl("error://resource/123"))
501
+ 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_function_resources.py CHANGED
@@ -80,7 +80,7 @@ class TestFunctionResource:
80
  name="test",
81
  fn=failing_func,
82
  )
83
- with pytest.raises(ValueError, match="Error reading resource function://test"):
84
  await resource.read()
85
 
86
  async def test_basemodel_conversion(self):
 
80
  name="test",
81
  fn=failing_func,
82
  )
83
+ with pytest.raises(ValueError, match="Test error"):
84
  await resource.read()
85
 
86
  async def test_basemodel_conversion(self):
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,87 @@ 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
+ with pytest.raises(ResourceError) as excinfo:
604
+ await manager.read_resource("error://test")
605
+
606
+ # The original error message should be included in the ValueError
607
+ assert "Template error with param test" in str(excinfo.value)
608
+
609
+ async def test_template_exception_converted_to_resource_error(self):
610
+ """Test that other exceptions from template-generated resources are converted."""
611
+ manager = ResourceManager()
612
+
613
+ def buggy_template(param: str):
614
+ """Template that raises a ValueError."""
615
+ raise ValueError(f"Internal template error with {param}")
616
+
617
+ template = ResourceTemplate.from_function(
618
+ fn=buggy_template,
619
+ uri_template="buggy://{param}",
620
+ name="buggy_template",
621
+ )
622
+ manager.add_template(template)
623
+
624
+ # First, the template creation will fail with ValueError
625
+ with pytest.raises(ResourceError, match="Error reading resource"):
626
+ await manager.read_resource("buggy://test")
tests/resources/test_resource_template.py CHANGED
@@ -186,21 +186,6 @@ class TestResourceTemplate:
186
  data = json.loads(content)
187
  assert data == {"key": "foo", "value": 123}
188
 
189
- async def test_template_error(self):
190
- """Test error handling in template resource creation."""
191
-
192
- def failing_func(x: str) -> str:
193
- raise ValueError("Test error")
194
-
195
- template = ResourceTemplate.from_function(
196
- fn=failing_func,
197
- uri_template="fail://{x}",
198
- name="fail",
199
- )
200
-
201
- with pytest.raises(ValueError, match="Error creating resource from template"):
202
- await template.create_resource("fail://test", {"x": "test"})
203
-
204
  async def test_async_text_resource(self):
205
  """Test creating a text resource from async function."""
206
 
 
186
  data = json.loads(content)
187
  assert data == {"key": "foo", "value": 123}
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  async def test_async_text_resource(self):
190
  """Test creating a text resource from async function."""
191
 
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)