Jeremiah Lowin commited on
Commit
85e9cfd
·
1 Parent(s): f8721ad

Make error masking configurable

Browse files
docs/servers/resources.mdx CHANGED
@@ -408,12 +408,20 @@ Templates provide a powerful way to expose parameterized data access points foll
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
@@ -423,13 +431,14 @@ mcp = FastMCP(name="DataServer")
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}")
@@ -442,7 +451,7 @@ def get_data_by_id(id: str) -> dict:
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
 
 
408
 
409
  ## Error Handling
410
 
411
+ <VersionBadge version="2.4.1" />
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
+ By default, all exceptions (including their details) are logged and converted into an MCP error response to be sent back to the client LLM. This helps the LLM understand failures and react appropriately.
416
 
417
+ If you want to mask internal error details for security reasons, you can:
418
+
419
+ 1. Use the `mask_error_details=True` parameter when creating your `FastMCP` instance:
420
+ ```python
421
+ mcp = FastMCP(name="SecureServer", mask_error_details=True)
422
+ ```
423
+
424
+ 2. Or use `ResourceError` to explicitly control what error information is sent to clients:
425
  ```python
426
  from fastmcp import FastMCP
427
  from fastmcp.exceptions import ResourceError
 
431
  @mcp.resource("resource://safe-error")
432
  def fail_with_details() -> str:
433
  """This resource provides detailed error information."""
434
+ # ResourceError contents are always sent back to clients,
435
+ # regardless of mask_error_details setting
436
  raise ResourceError("Unable to retrieve data: file not found")
437
 
438
  @mcp.resource("resource://masked-error")
439
  def fail_with_masked_details() -> str:
440
+ """This resource masks internal error details when mask_error_details=True."""
441
+ # This message would be masked if mask_error_details=True
442
  raise ValueError("Sensitive internal file path: /etc/secrets.conf")
443
 
444
  @mcp.resource("data://{id}")
 
451
  return {"id": id, "value": "data"}
452
  ```
453
 
454
+ When `mask_error_details=True`, only error messages from `ResourceError` will include details, other exceptions will be converted to a generic message.
455
 
456
  ## Server Behavior
457
 
docs/servers/tools.mdx CHANGED
@@ -248,13 +248,21 @@ def do_nothing() -> None:
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
 
@@ -262,16 +270,20 @@ from fastmcp.exceptions import ToolError
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" />
 
248
 
249
  ### Error Handling
250
 
251
+ <VersionBadge version="2.4.1" />
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
+ By default, all exceptions (including their details) are logged and converted into an MCP error response to be sent back to the client LLM. This helps the LLM understand failures and react appropriately.
256
+
257
+ If you want to mask internal error details for security reasons, you can:
258
 
259
+ 1. Use the `mask_error_details=True` parameter when creating your `FastMCP` instance:
260
+ ```python
261
+ mcp = FastMCP(name="SecureServer", mask_error_details=True)
262
+ ```
263
+
264
+ 2. Or use `ToolError` to explicitly control what error information is sent to clients:
265
+ ```python
266
  from fastmcp import FastMCP
267
  from fastmcp.exceptions import ToolError
268
 
 
270
  def divide(a: float, b: float) -> float:
271
  """Divide a by b."""
272
 
 
 
 
 
273
  if b == 0:
274
+ # Error messages from ToolError are always sent to clients,
275
+ # regardless of mask_error_details setting
276
  raise ToolError("Division by zero is not allowed.")
277
+
278
+ # If mask_error_details=True, this message would be masked
279
+ if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
280
+ raise TypeError("Both arguments must be numbers.")
281
+
282
  return a / b
283
  ```
284
 
285
+ When `mask_error_details=True`, only error messages from `ToolError` will include details, other exceptions will be converted to a generic message.
286
+
287
  ### Annotations
288
 
289
  <VersionBadge version="2.2.7" />
src/fastmcp/client/client.py CHANGED
@@ -322,7 +322,12 @@ class Client:
322
  RuntimeError: If called while the client is not connected.
323
  """
324
  if isinstance(uri, str):
325
- uri = AnyUrl(uri) # Ensure AnyUrl
 
 
 
 
 
326
  result = await self.read_resource_mcp(uri)
327
  return result.contents
328
 
 
322
  RuntimeError: If called while the client is not connected.
323
  """
324
  if isinstance(uri, str):
325
+ try:
326
+ uri = AnyUrl(uri) # Ensure AnyUrl
327
+ except Exception as e:
328
+ raise ValueError(
329
+ f"Provided resource URI is invalid: {str(uri)!r}"
330
+ ) from e
331
  result = await self.read_resource_mcp(uri)
332
  return result.contents
333
 
src/fastmcp/prompts/prompt.py CHANGED
@@ -12,6 +12,7 @@ from mcp.types import Prompt as MCPPrompt
12
  from mcp.types import PromptArgument as MCPPromptArgument
13
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
14
 
 
15
  from fastmcp.server.dependencies import get_context
16
  from fastmcp.utilities.json_schema import compress_schema
17
  from fastmcp.utilities.logging import get_logger
@@ -199,12 +200,12 @@ class Prompt(BaseModel):
199
  )
200
  )
201
  except Exception:
202
- raise ValueError("Could not convert prompt result to message.")
203
 
204
  return messages
205
  except Exception as e:
206
  logger.exception(f"Error rendering prompt {self.name}: {e}")
207
- raise ValueError(f"Error rendering prompt {self.name}.")
208
 
209
  def __eq__(self, other: object) -> bool:
210
  if not isinstance(other, Prompt):
 
12
  from mcp.types import PromptArgument as MCPPromptArgument
13
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
14
 
15
+ from fastmcp.exceptions import PromptError
16
  from fastmcp.server.dependencies import get_context
17
  from fastmcp.utilities.json_schema import compress_schema
18
  from fastmcp.utilities.logging import get_logger
 
200
  )
201
  )
202
  except Exception:
203
+ raise PromptError("Could not convert prompt result to message.")
204
 
205
  return messages
206
  except Exception as e:
207
  logger.exception(f"Error rendering prompt {self.name}: {e}")
208
+ raise PromptError(f"Error rendering prompt {self.name}.")
209
 
210
  def __eq__(self, other: object) -> bool:
211
  if not isinstance(other, Prompt):
src/fastmcp/prompts/prompt_manager.py CHANGED
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
7
 
8
  from mcp import GetPromptResult
9
 
10
- from fastmcp.exceptions import NotFoundError
11
  from fastmcp.prompts.prompt import Prompt, PromptResult
12
  from fastmcp.settings import DuplicateBehavior
13
  from fastmcp.utilities.logging import get_logger
@@ -21,8 +21,13 @@ logger = get_logger(__name__)
21
  class PromptManager:
22
  """Manages FastMCP prompts."""
23
 
24
- def __init__(self, duplicate_behavior: DuplicateBehavior | None = None):
 
 
 
 
25
  self._prompts: dict[str, Prompt] = {}
 
26
 
27
  # Default to "warn" if None is provided
28
  if duplicate_behavior is None:
@@ -85,9 +90,24 @@ class PromptManager:
85
  if not prompt:
86
  raise NotFoundError(f"Unknown prompt: {name}")
87
 
88
- messages = await prompt.render(arguments)
89
-
90
- return GetPromptResult(description=prompt.description, messages=messages)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
  def has_prompt(self, key: str) -> bool:
93
  """Check if a prompt exists."""
 
7
 
8
  from mcp import GetPromptResult
9
 
10
+ from fastmcp.exceptions import NotFoundError, PromptError
11
  from fastmcp.prompts.prompt import Prompt, PromptResult
12
  from fastmcp.settings import DuplicateBehavior
13
  from fastmcp.utilities.logging import get_logger
 
21
  class PromptManager:
22
  """Manages FastMCP prompts."""
23
 
24
+ def __init__(
25
+ self,
26
+ duplicate_behavior: DuplicateBehavior | None = None,
27
+ mask_error_details: bool = False,
28
+ ):
29
  self._prompts: dict[str, Prompt] = {}
30
+ self.mask_error_details = mask_error_details
31
 
32
  # Default to "warn" if None is provided
33
  if duplicate_behavior is None:
 
90
  if not prompt:
91
  raise NotFoundError(f"Unknown prompt: {name}")
92
 
93
+ try:
94
+ messages = await prompt.render(arguments)
95
+ return GetPromptResult(description=prompt.description, messages=messages)
96
+
97
+ # Pass through PromptErrors as-is
98
+ except PromptError as e:
99
+ logger.exception(f"Error rendering prompt {name!r}: {e}")
100
+ raise e
101
+
102
+ # Handle other exceptions
103
+ except Exception as e:
104
+ logger.exception(f"Error rendering prompt {name!r}: {e}")
105
+ if self.mask_error_details:
106
+ # Mask internal details
107
+ raise PromptError(f"Error rendering prompt {name!r}")
108
+ else:
109
+ # Include original error details
110
+ raise PromptError(f"Error rendering prompt {name!r}: {e}")
111
 
112
  def has_prompt(self, key: str) -> bool:
113
  """Check if a prompt exists."""
src/fastmcp/resources/resource_manager.py CHANGED
@@ -22,9 +22,22 @@ logger = get_logger(__name__)
22
  class ResourceManager:
23
  """Manages FastMCP resources."""
24
 
25
- def __init__(self, duplicate_behavior: DuplicateBehavior | None = None):
 
 
 
 
 
 
 
 
 
 
 
 
26
  self._resources: dict[str, Resource] = {}
27
  self._templates: dict[str, ResourceTemplate] = {}
 
28
 
29
  # Default to "warn" if None is provided
30
  if duplicate_behavior is None:
@@ -35,7 +48,6 @@ class ResourceManager:
35
  f"Invalid duplicate_behavior: {duplicate_behavior}. "
36
  f"Must be one of: {', '.join(DuplicateBehavior.__args__)}"
37
  )
38
-
39
  self.duplicate_behavior = duplicate_behavior
40
 
41
  def add_resource_or_template_from_fn(
@@ -244,12 +256,21 @@ class ResourceManager:
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
 
@@ -265,10 +286,15 @@ class ResourceManager:
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."""
 
22
  class ResourceManager:
23
  """Manages FastMCP resources."""
24
 
25
+ def __init__(
26
+ self,
27
+ duplicate_behavior: DuplicateBehavior | None = None,
28
+ mask_error_details: bool = False,
29
+ ):
30
+ """Initialize the ResourceManager.
31
+
32
+ Args:
33
+ duplicate_behavior: How to handle duplicate resources
34
+ (warn, error, replace, ignore)
35
+ mask_error_details: Whether to mask error details from exceptions
36
+ other than ResourceError
37
+ """
38
  self._resources: dict[str, Resource] = {}
39
  self._templates: dict[str, ResourceTemplate] = {}
40
+ self.mask_error_details = mask_error_details
41
 
42
  # Default to "warn" if None is provided
43
  if duplicate_behavior is None:
 
48
  f"Invalid duplicate_behavior: {duplicate_behavior}. "
49
  f"Must be one of: {', '.join(DuplicateBehavior.__args__)}"
50
  )
 
51
  self.duplicate_behavior = duplicate_behavior
52
 
53
  def add_resource_or_template_from_fn(
 
256
  uri_str,
257
  params=params,
258
  )
259
+ # Pass through ResourceErrors as-is
260
  except ResourceError as e:
261
  logger.error(f"Error creating resource from template: {e}")
262
  raise e
263
+ # Handle other exceptions
264
  except Exception as e:
265
  logger.error(f"Error creating resource from template: {e}")
266
+ if self.mask_error_details:
267
+ # Mask internal details
268
+ raise ValueError("Error creating resource from template") from e
269
+ else:
270
+ # Include original error details
271
+ raise ValueError(
272
+ f"Error creating resource from template: {e}"
273
+ ) from e
274
 
275
  raise NotFoundError(f"Unknown resource: {uri_str}")
276
 
 
286
  logger.error(f"Error reading resource {uri!r}: {e}")
287
  raise e
288
 
289
+ # Handle other exceptions
290
  except Exception as e:
291
  logger.error(f"Error reading resource {uri!r}: {e}")
292
+ if self.mask_error_details:
293
+ # Mask internal details
294
+ raise ResourceError(f"Error reading resource {uri!r}") from e
295
+ else:
296
+ # Include original error details
297
+ raise ResourceError(f"Error reading resource {uri!r}: {e}") from e
298
 
299
  def get_resources(self) -> dict[str, Resource]:
300
  """Get all registered resources, keyed by URI."""
src/fastmcp/server/server.py CHANGED
@@ -125,6 +125,7 @@ class FastMCP(Generic[LifespanResultT]):
125
  on_duplicate_resources: DuplicateBehavior | None = None,
126
  on_duplicate_prompts: DuplicateBehavior | None = None,
127
  resource_prefix_format: Literal["protocol", "path"] | None = None,
 
128
  **settings: Any,
129
  ):
130
  if settings:
@@ -139,6 +140,10 @@ class FastMCP(Generic[LifespanResultT]):
139
  )
140
  self.settings = fastmcp.settings.ServerSettings(**settings)
141
 
 
 
 
 
142
  self.resource_prefix_format: Literal["protocol", "path"]
143
  if resource_prefix_format is None:
144
  self.resource_prefix_format = (
@@ -157,11 +162,16 @@ class FastMCP(Generic[LifespanResultT]):
157
  self._tool_manager = ToolManager(
158
  duplicate_behavior=on_duplicate_tools,
159
  serializer=tool_serializer,
 
160
  )
161
  self._resource_manager = ResourceManager(
162
- duplicate_behavior=on_duplicate_resources
 
 
 
 
 
163
  )
164
- self._prompt_manager = PromptManager(duplicate_behavior=on_duplicate_prompts)
165
 
166
  if lifespan is None:
167
  self._has_lifespan = False
@@ -377,21 +387,30 @@ class FastMCP(Generic[LifespanResultT]):
377
  async def _mcp_call_tool(
378
  self, key: str, arguments: dict[str, Any]
379
  ) -> list[TextContent | ImageContent | EmbeddedResource]:
380
- """Call a tool by name with arguments."""
 
 
 
 
 
 
 
 
 
381
 
 
382
  with fastmcp.server.context.Context(fastmcp=self):
 
383
  if self._tool_manager.has_tool(key):
384
- result = await self._tool_manager.call_tool(key, arguments)
385
 
386
- else:
387
- for server in self._mounted_servers.values():
388
- if server.match_tool(key):
389
- new_key = server.strip_tool_prefix(key)
390
- result = await server.server._mcp_call_tool(new_key, arguments)
391
- break
392
- else:
393
- raise NotFoundError(f"Unknown tool: {key}")
394
- return result
395
 
396
  async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
397
  """
@@ -419,24 +438,30 @@ class FastMCP(Generic[LifespanResultT]):
419
  async def _mcp_get_prompt(
420
  self, name: str, arguments: dict[str, Any] | None = None
421
  ) -> GetPromptResult:
422
- """
423
- Get a prompt by name with arguments, in the format expected by the low-level
424
- MCP server.
 
 
425
 
 
 
426
  """
 
 
 
427
  with fastmcp.server.context.Context(fastmcp=self):
 
428
  if self._prompt_manager.has_prompt(name):
429
- prompt_result = await self._prompt_manager.render_prompt(
430
- name, arguments=arguments or {}
431
- )
432
- return prompt_result
433
- else:
434
- for server in self._mounted_servers.values():
435
- if server.match_prompt(name):
436
- new_key = server.strip_prompt_prefix(name)
437
- return await server.server._mcp_get_prompt(new_key, arguments)
438
- else:
439
- raise NotFoundError(f"Unknown prompt: {name}")
440
 
441
  def add_tool(
442
  self,
 
125
  on_duplicate_resources: DuplicateBehavior | None = None,
126
  on_duplicate_prompts: DuplicateBehavior | None = None,
127
  resource_prefix_format: Literal["protocol", "path"] | None = None,
128
+ mask_error_details: bool | None = None,
129
  **settings: Any,
130
  ):
131
  if settings:
 
140
  )
141
  self.settings = fastmcp.settings.ServerSettings(**settings)
142
 
143
+ # If mask_error_details is provided, override the settings value
144
+ if mask_error_details is not None:
145
+ self.settings.mask_error_details = mask_error_details
146
+
147
  self.resource_prefix_format: Literal["protocol", "path"]
148
  if resource_prefix_format is None:
149
  self.resource_prefix_format = (
 
162
  self._tool_manager = ToolManager(
163
  duplicate_behavior=on_duplicate_tools,
164
  serializer=tool_serializer,
165
+ mask_error_details=self.settings.mask_error_details,
166
  )
167
  self._resource_manager = ResourceManager(
168
+ duplicate_behavior=on_duplicate_resources,
169
+ mask_error_details=self.settings.mask_error_details,
170
+ )
171
+ self._prompt_manager = PromptManager(
172
+ duplicate_behavior=on_duplicate_prompts,
173
+ mask_error_details=self.settings.mask_error_details,
174
  )
 
175
 
176
  if lifespan is None:
177
  self._has_lifespan = False
 
387
  async def _mcp_call_tool(
388
  self, key: str, arguments: dict[str, Any]
389
  ) -> list[TextContent | ImageContent | EmbeddedResource]:
390
+ """Handle MCP 'callTool' requests.
391
+
392
+ Args:
393
+ key: The name of the tool to call
394
+ arguments: Arguments to pass to the tool
395
+
396
+ Returns:
397
+ List of MCP Content objects containing the tool results
398
+ """
399
+ logger.debug("Call tool: %s with %s", key, arguments)
400
 
401
+ # Create and use context for the entire call
402
  with fastmcp.server.context.Context(fastmcp=self):
403
+ # Get tool, checking first from our tools, then from the mounted servers
404
  if self._tool_manager.has_tool(key):
405
+ return await self._tool_manager.call_tool(key, arguments)
406
 
407
+ # Check mounted servers to see if they have the tool
408
+ for server in self._mounted_servers.values():
409
+ if server.match_tool(key):
410
+ tool_key = server.strip_tool_prefix(key)
411
+ return await server.server._mcp_call_tool(tool_key, arguments)
412
+
413
+ raise NotFoundError(f"Unknown tool: {key}")
 
 
414
 
415
  async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
416
  """
 
438
  async def _mcp_get_prompt(
439
  self, name: str, arguments: dict[str, Any] | None = None
440
  ) -> GetPromptResult:
441
+ """Handle MCP 'getPrompt' requests.
442
+
443
+ Args:
444
+ name: The name of the prompt to render
445
+ arguments: Arguments to pass to the prompt
446
 
447
+ Returns:
448
+ GetPromptResult containing the rendered prompt messages
449
  """
450
+ logger.debug("Get prompt: %s with %s", name, arguments)
451
+
452
+ # Create and use context for the entire call
453
  with fastmcp.server.context.Context(fastmcp=self):
454
+ # Get prompt, checking first from our prompts, then from the mounted servers
455
  if self._prompt_manager.has_prompt(name):
456
+ return await self._prompt_manager.render_prompt(name, arguments)
457
+
458
+ # Check mounted servers to see if they have the prompt
459
+ for server in self._mounted_servers.values():
460
+ if server.match_prompt(name):
461
+ prompt_name = server.strip_prompt_prefix(name)
462
+ return await server.server._mcp_get_prompt(prompt_name, arguments)
463
+
464
+ raise NotFoundError(f"Unknown prompt: {name}")
 
 
465
 
466
  def add_tool(
467
  self,
src/fastmcp/settings.py CHANGED
@@ -124,6 +124,23 @@ class ServerSettings(BaseSettings):
124
  # prompt settings
125
  on_duplicate_prompts: DuplicateBehavior = "warn"
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  dependencies: Annotated[
128
  list[str],
129
  Field(
 
124
  # prompt settings
125
  on_duplicate_prompts: DuplicateBehavior = "warn"
126
 
127
+ # error handling
128
+ mask_error_details: Annotated[
129
+ bool,
130
+ Field(
131
+ default=False,
132
+ description=inspect.cleandoc(
133
+ """
134
+ If True, error details from user-supplied functions (tool, resource, prompt)
135
+ will be masked before being sent to clients. Only error messages from explicitly
136
+ raised ToolError, ResourceError, or PromptError will be included in responses.
137
+ If False (default), all error details will be included in responses, but prefixed
138
+ with appropriate context.
139
+ """
140
+ ),
141
+ ),
142
+ ] = False
143
+
144
  dependencies: Annotated[
145
  list[str],
146
  Field(
src/fastmcp/tools/tool_manager.py CHANGED
@@ -23,9 +23,11 @@ class ToolManager:
23
  self,
24
  duplicate_behavior: DuplicateBehavior | None = None,
25
  serializer: Callable[[Any], str] | None = None,
 
26
  ):
27
  self._tools: dict[str, Tool] = {}
28
  self._serializer = serializer
 
29
 
30
  # Default to "warn" if None is provided
31
  if duplicate_behavior is None:
@@ -124,7 +126,12 @@ class ToolManager:
124
  logger.exception(f"Error calling tool {key!r}: {e}")
125
  raise e
126
 
127
- # raise other exceptions as ToolErrors without revealing internal details
128
  except Exception as e:
129
  logger.exception(f"Error calling tool {key!r}: {e}")
130
- raise ToolError(f"Error calling tool {key!r}") from e
 
 
 
 
 
 
23
  self,
24
  duplicate_behavior: DuplicateBehavior | None = None,
25
  serializer: Callable[[Any], str] | None = None,
26
+ mask_error_details: bool = False,
27
  ):
28
  self._tools: dict[str, Tool] = {}
29
  self._serializer = serializer
30
+ self.mask_error_details = mask_error_details
31
 
32
  # Default to "warn" if None is provided
33
  if duplicate_behavior is None:
 
126
  logger.exception(f"Error calling tool {key!r}: {e}")
127
  raise e
128
 
129
+ # Handle other exceptions
130
  except Exception as e:
131
  logger.exception(f"Error calling tool {key!r}: {e}")
132
+ if self.mask_error_details:
133
+ # Mask internal details
134
+ raise ToolError(f"Error calling tool {key!r}") from e
135
+ else:
136
+ # Include original error details
137
+ raise ToolError(f"Error calling tool {key!r}: {e}") from e
tests/client/test_client.py CHANGED
@@ -219,6 +219,13 @@ async def test_get_prompt_mcp(fastmcp_server):
219
  assert result.description == "Example greeting prompt."
220
 
221
 
 
 
 
 
 
 
 
222
  async def test_read_resource(fastmcp_server):
223
  """Test reading a resource with InMemoryClient."""
224
  client = Client(transport=FastMCPTransport(fastmcp_server))
@@ -457,7 +464,7 @@ async def test_tagged_template_functionality(tagged_resources_server):
457
 
458
 
459
  class TestErrorHandling:
460
- async def test_general_tool_exceptions_are_masked(self):
461
  mcp = FastMCP("TestServer")
462
 
463
  @mcp.tool()
@@ -466,6 +473,22 @@ class TestErrorHandling:
466
 
467
  client = Client(transport=FastMCPTransport(mcp))
468
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  async with client:
470
  result = await client.call_tool_mcp("error_tool", {})
471
  assert result.isError
@@ -489,7 +512,7 @@ class TestErrorHandling:
489
  assert "test error" in result.content[0].text
490
  assert "abc" in result.content[0].text
491
 
492
- async def test_general_resource_exceptions_are_masked(self):
493
  mcp = FastMCP("TestServer")
494
 
495
  @mcp.resource(uri="exception://resource")
@@ -498,6 +521,22 @@ class TestErrorHandling:
498
 
499
  client = Client(transport=FastMCPTransport(mcp))
500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  async with client:
502
  with pytest.raises(Exception) as excinfo:
503
  await client.read_resource(AnyUrl("exception://resource"))
@@ -519,7 +558,7 @@ class TestErrorHandling:
519
  await client.read_resource(AnyUrl("error://resource"))
520
  assert "This is a resource error (xyz)" in str(excinfo.value)
521
 
522
- async def test_general_template_exceptions_are_masked(self):
523
  mcp = FastMCP("TestServer")
524
 
525
  @mcp.resource(uri="exception://resource/{id}")
@@ -528,6 +567,22 @@ class TestErrorHandling:
528
 
529
  client = Client(transport=FastMCPTransport(mcp))
530
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  async with client:
532
  with pytest.raises(Exception) as excinfo:
533
  await client.read_resource(AnyUrl("exception://resource/123"))
 
219
  assert result.description == "Example greeting prompt."
220
 
221
 
222
+ async def test_read_resource_invalid_uri(fastmcp_server):
223
+ """Test reading a resource with an invalid URI."""
224
+ client = Client(transport=FastMCPTransport(fastmcp_server))
225
+ with pytest.raises(ValueError, match="Provided resource URI is invalid"):
226
+ await client.read_resource("invalid_uri")
227
+
228
+
229
  async def test_read_resource(fastmcp_server):
230
  """Test reading a resource with InMemoryClient."""
231
  client = Client(transport=FastMCPTransport(fastmcp_server))
 
464
 
465
 
466
  class TestErrorHandling:
467
+ async def test_general_tool_exceptions_are_not_masked_by_default(self):
468
  mcp = FastMCP("TestServer")
469
 
470
  @mcp.tool()
 
473
 
474
  client = Client(transport=FastMCPTransport(mcp))
475
 
476
+ async with client:
477
+ result = await client.call_tool_mcp("error_tool", {})
478
+ assert result.isError
479
+ assert isinstance(result.content[0], TextContent)
480
+ assert "test error" in result.content[0].text
481
+ assert "abc" in result.content[0].text
482
+
483
+ async def test_general_tool_exceptions_are_masked_when_enabled(self):
484
+ mcp = FastMCP("TestServer", mask_error_details=True)
485
+
486
+ @mcp.tool()
487
+ def error_tool():
488
+ raise ValueError("This is a test error (abc)")
489
+
490
+ client = Client(transport=FastMCPTransport(mcp))
491
+
492
  async with client:
493
  result = await client.call_tool_mcp("error_tool", {})
494
  assert result.isError
 
512
  assert "test error" in result.content[0].text
513
  assert "abc" in result.content[0].text
514
 
515
+ async def test_general_resource_exceptions_are_not_masked_by_default(self):
516
  mcp = FastMCP("TestServer")
517
 
518
  @mcp.resource(uri="exception://resource")
 
521
 
522
  client = Client(transport=FastMCPTransport(mcp))
523
 
524
+ async with client:
525
+ with pytest.raises(Exception) as excinfo:
526
+ await client.read_resource(AnyUrl("exception://resource"))
527
+ assert "Error reading resource" in str(excinfo.value)
528
+ assert "sensitive" in str(excinfo.value)
529
+ assert "internal error" in str(excinfo.value)
530
+
531
+ async def test_general_resource_exceptions_are_masked_when_enabled(self):
532
+ mcp = FastMCP("TestServer", mask_error_details=True)
533
+
534
+ @mcp.resource(uri="exception://resource")
535
+ async def exception_resource():
536
+ raise ValueError("This is an internal error (sensitive)")
537
+
538
+ client = Client(transport=FastMCPTransport(mcp))
539
+
540
  async with client:
541
  with pytest.raises(Exception) as excinfo:
542
  await client.read_resource(AnyUrl("exception://resource"))
 
558
  await client.read_resource(AnyUrl("error://resource"))
559
  assert "This is a resource error (xyz)" in str(excinfo.value)
560
 
561
+ async def test_general_template_exceptions_are_not_masked_by_default(self):
562
  mcp = FastMCP("TestServer")
563
 
564
  @mcp.resource(uri="exception://resource/{id}")
 
567
 
568
  client = Client(transport=FastMCPTransport(mcp))
569
 
570
+ async with client:
571
+ with pytest.raises(Exception) as excinfo:
572
+ await client.read_resource(AnyUrl("exception://resource/123"))
573
+ assert "Error reading resource" in str(excinfo.value)
574
+ assert "sensitive" in str(excinfo.value)
575
+ assert "internal error" in str(excinfo.value)
576
+
577
+ async def test_general_template_exceptions_are_masked_when_enabled(self):
578
+ mcp = FastMCP("TestServer", mask_error_details=True)
579
+
580
+ @mcp.resource(uri="exception://resource/{id}")
581
+ async def exception_resource(id: str):
582
+ raise ValueError("This is an internal error (sensitive)")
583
+
584
+ client = Client(transport=FastMCPTransport(mcp))
585
+
586
  async with client:
587
  with pytest.raises(Exception) as excinfo:
588
  await client.read_resource(AnyUrl("exception://resource/123"))
tests/contrib/test_bulk_tool_caller.py CHANGED
@@ -27,7 +27,9 @@ 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
- formatted_error_text = "Error calling tool 'error_tool'"
 
 
31
  return CallToolRequestResult(
32
  isError=True,
33
  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 = (
31
+ "Error calling tool 'error_tool': Error in tool with arg1: " + arg1
32
+ )
33
  return CallToolRequestResult(
34
  isError=True,
35
  content=[TextContent(text=formatted_error_text, type="text")],
tests/prompts/test_prompt_manager.py CHANGED
@@ -3,7 +3,7 @@ from typing import Annotated
3
  import pytest
4
 
5
  from fastmcp import Context
6
- from fastmcp.exceptions import NotFoundError
7
  from fastmcp.prompts import Prompt
8
  from fastmcp.prompts.prompt import PromptMessage, TextContent
9
  from fastmcp.prompts.prompt_manager import PromptManager
@@ -192,7 +192,7 @@ class TestPromptManager:
192
  manager = PromptManager()
193
  prompt = Prompt.from_function(fn)
194
  manager.add_prompt(prompt)
195
- with pytest.raises(ValueError, match="Missing required arguments"):
196
  await manager.render_prompt("fn")
197
 
198
  async def test_prompt_with_varargs_not_allowed(self):
 
3
  import pytest
4
 
5
  from fastmcp import Context
6
+ from fastmcp.exceptions import NotFoundError, PromptError
7
  from fastmcp.prompts import Prompt
8
  from fastmcp.prompts.prompt import PromptMessage, TextContent
9
  from fastmcp.prompts.prompt_manager import PromptManager
 
192
  manager = PromptManager()
193
  prompt = Prompt.from_function(fn)
194
  manager.add_prompt(prompt)
195
+ with pytest.raises(PromptError, match="Missing required arguments"):
196
  await manager.render_prompt("fn")
197
 
198
  async def test_prompt_with_varargs_not_allowed(self):
tests/resources/test_resource_manager.py CHANGED
@@ -563,28 +563,6 @@ class TestResourceErrorHandling:
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()
@@ -606,21 +584,46 @@ class TestResourceErrorHandling:
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")
 
 
 
 
 
 
 
 
 
 
 
563
  with pytest.raises(ResourceError, match="Specific resource error"):
564
  await manager.read_resource("error://resource")
565
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  async def test_template_resource_error_passthrough(self):
567
  """Test that ResourceErrors from template-generated resources are passed through."""
568
  manager = ResourceManager()
 
584
  # The original error message should be included in the ValueError
585
  assert "Template error with param test" in str(excinfo.value)
586
 
587
+ async def test_exception_converted_to_resource_error_with_details(self):
588
+ """Test that other exceptions are converted to ResourceError with details by default."""
589
  manager = ResourceManager()
590
 
591
+ async def buggy_resource():
592
+ """Resource that raises a ValueError."""
593
+ raise ValueError("Internal error details")
594
 
595
+ resource = FunctionResource(
596
+ uri=AnyUrl("buggy://resource"),
597
+ name="buggy_resource",
598
+ fn=buggy_resource,
599
  )
600
+ manager.add_resource(resource)
601
+
602
+ with pytest.raises(ResourceError) as excinfo:
603
+ await manager.read_resource("buggy://resource")
604
+
605
+ # The error message should include the original exception details
606
+ assert "Error reading resource 'buggy://resource'" in str(excinfo.value)
607
+ assert "Internal error details" in str(excinfo.value)
608
+
609
+ async def test_exception_converted_to_masked_resource_error(self):
610
+ """Test that other exceptions are masked when enabled."""
611
+ manager = ResourceManager(mask_error_details=True)
612
+
613
+ async def buggy_resource():
614
+ """Resource that raises a ValueError."""
615
+ raise ValueError("Internal error details")
616
 
617
+ resource = FunctionResource(
618
+ uri=AnyUrl("buggy://resource"),
619
+ name="buggy_resource",
620
+ fn=buggy_resource,
621
+ )
622
+ manager.add_resource(resource)
623
+
624
+ with pytest.raises(ResourceError) as excinfo:
625
+ await manager.read_resource("buggy://resource")
626
+
627
+ # The error message should not include the original exception details
628
+ assert "Error reading resource 'buggy://resource'" in str(excinfo.value)
629
+ assert "Internal error details" not in str(excinfo.value)
tests/tools/test_tool_manager.py CHANGED
@@ -778,8 +778,8 @@ class TestToolErrorHandling:
778
  with pytest.raises(ToolError, match="Specific tool error"):
779
  await manager.call_tool("error_tool", {"x": 42})
780
 
781
- async def test_exception_converted_to_tool_error(self):
782
- """Test that other exceptions are converted to ToolError."""
783
  manager = ToolManager()
784
 
785
  def buggy_tool(x: int) -> int:
@@ -791,7 +791,24 @@ class TestToolErrorHandling:
791
  with pytest.raises(ToolError) as excinfo:
792
  await manager.call_tool("buggy_tool", {"x": 42})
793
 
794
- # Exception message should contain the tool name but not the internal details
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
795
  assert "Error calling tool 'buggy_tool'" in str(excinfo.value)
796
  assert "Internal error details" not in str(excinfo.value)
797
 
@@ -808,8 +825,8 @@ class TestToolErrorHandling:
808
  with pytest.raises(ToolError, match="Async tool error"):
809
  await manager.call_tool("async_error_tool", {"x": 42})
810
 
811
- async def test_async_exception_converted_to_tool_error(self):
812
- """Test that other exceptions from async tools are converted to ToolError."""
813
  manager = ToolManager()
814
 
815
  async def async_buggy_tool(x: int) -> int:
@@ -818,6 +835,23 @@ class TestToolErrorHandling:
818
 
819
  manager.add_tool_from_fn(async_buggy_tool)
820
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
821
  with pytest.raises(ToolError) as excinfo:
822
  await manager.call_tool("async_buggy_tool", {"x": 42})
823
 
 
778
  with pytest.raises(ToolError, match="Specific tool error"):
779
  await manager.call_tool("error_tool", {"x": 42})
780
 
781
+ async def test_exception_converted_to_tool_error_with_details(self):
782
+ """Test that other exceptions include details by default."""
783
  manager = ToolManager()
784
 
785
  def buggy_tool(x: int) -> int:
 
791
  with pytest.raises(ToolError) as excinfo:
792
  await manager.call_tool("buggy_tool", {"x": 42})
793
 
794
+ # Exception message should include the tool name and the internal details
795
+ assert "Error calling tool 'buggy_tool'" in str(excinfo.value)
796
+ assert "Internal error details" in str(excinfo.value)
797
+
798
+ async def test_exception_converted_to_masked_tool_error(self):
799
+ """Test that other exceptions are masked when enabled."""
800
+ manager = ToolManager(mask_error_details=True)
801
+
802
+ def buggy_tool(x: int) -> int:
803
+ """Tool that raises a ValueError."""
804
+ raise ValueError("Internal error details")
805
+
806
+ manager.add_tool_from_fn(buggy_tool)
807
+
808
+ with pytest.raises(ToolError) as excinfo:
809
+ await manager.call_tool("buggy_tool", {"x": 42})
810
+
811
+ # Exception message should only contain the tool name, not the internal details
812
  assert "Error calling tool 'buggy_tool'" in str(excinfo.value)
813
  assert "Internal error details" not in str(excinfo.value)
814
 
 
825
  with pytest.raises(ToolError, match="Async tool error"):
826
  await manager.call_tool("async_error_tool", {"x": 42})
827
 
828
+ async def test_async_exception_converted_to_tool_error_with_details(self):
829
+ """Test that other exceptions from async tools include details by default."""
830
  manager = ToolManager()
831
 
832
  async def async_buggy_tool(x: int) -> int:
 
835
 
836
  manager.add_tool_from_fn(async_buggy_tool)
837
 
838
+ with pytest.raises(ToolError) as excinfo:
839
+ await manager.call_tool("async_buggy_tool", {"x": 42})
840
+
841
+ # Exception message should include the tool name and the internal details
842
+ assert "Error calling tool 'async_buggy_tool'" in str(excinfo.value)
843
+ assert "Internal async error details" in str(excinfo.value)
844
+
845
+ async def test_async_exception_converted_to_masked_tool_error(self):
846
+ """Test that other exceptions from async tools are masked when enabled."""
847
+ manager = ToolManager(mask_error_details=True)
848
+
849
+ async def async_buggy_tool(x: int) -> int:
850
+ """Async tool that raises a ValueError."""
851
+ raise ValueError("Internal async error details")
852
+
853
+ manager.add_tool_from_fn(async_buggy_tool)
854
+
855
  with pytest.raises(ToolError) as excinfo:
856
  await manager.call_tool("async_buggy_tool", {"x": 42})
857