Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
85e9cfd
1
Parent(s): f8721ad
Make error masking configurable
Browse files- docs/servers/resources.mdx +15 -6
- docs/servers/tools.mdx +20 -8
- src/fastmcp/client/client.py +6 -1
- src/fastmcp/prompts/prompt.py +3 -2
- src/fastmcp/prompts/prompt_manager.py +25 -5
- src/fastmcp/resources/resource_manager.py +31 -5
- src/fastmcp/server/server.py +52 -27
- src/fastmcp/settings.py +17 -0
- src/fastmcp/tools/tool_manager.py +9 -2
- tests/client/test_client.py +58 -3
- tests/contrib/test_bulk_tool_caller.py +3 -1
- tests/prompts/test_prompt_manager.py +2 -2
- tests/resources/test_resource_manager.py +38 -35
- tests/tools/test_tool_manager.py +39 -5
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.
|
| 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 |
-
|
| 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 |
-
#
|
| 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 |
-
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
| 256 |
|
| 257 |
-
``
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 203 |
|
| 204 |
return messages
|
| 205 |
except Exception as e:
|
| 206 |
logger.exception(f"Error rendering prompt {self.name}: {e}")
|
| 207 |
-
raise
|
| 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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 269 |
except Exception as e:
|
| 270 |
logger.error(f"Error reading resource {uri!r}: {e}")
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
|
|
|
|
| 382 |
with fastmcp.server.context.Context(fastmcp=self):
|
|
|
|
| 383 |
if self._tool_manager.has_tool(key):
|
| 384 |
-
|
| 385 |
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 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 |
-
|
| 424 |
-
|
|
|
|
|
|
|
| 425 |
|
|
|
|
|
|
|
| 426 |
"""
|
|
|
|
|
|
|
|
|
|
| 427 |
with fastmcp.server.context.Context(fastmcp=self):
|
|
|
|
| 428 |
if self._prompt_manager.has_prompt(name):
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 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 |
-
#
|
| 128 |
except Exception as e:
|
| 129 |
logger.exception(f"Error calling tool {key!r}: {e}")
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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 =
|
|
|
|
|
|
|
| 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(
|
| 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
|
| 610 |
-
"""Test that other exceptions
|
| 611 |
manager = ResourceManager()
|
| 612 |
|
| 613 |
-
def
|
| 614 |
-
"""
|
| 615 |
-
raise ValueError(
|
| 616 |
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
)
|
| 622 |
-
manager.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 782 |
-
"""Test that other exceptions
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 812 |
-
"""Test that other exceptions from async tools
|
| 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 |
|