Spaces:
Running
Running
Merge pull request #434 from jlowin/errors
Browse filesImprove error handling for tools and resources
- docs/servers/resources.mdx +38 -9
- docs/servers/tools.mdx +15 -12
- src/fastmcp/prompts/prompt.py +6 -4
- src/fastmcp/resources/resource_manager.py +22 -1
- src/fastmcp/resources/template.py +16 -17
- src/fastmcp/resources/types.py +25 -27
- src/fastmcp/server/openapi.py +2 -1
- src/fastmcp/server/proxy.py +4 -4
- src/fastmcp/server/server.py +8 -11
- src/fastmcp/tools/tool.py +39 -44
- src/fastmcp/tools/tool_manager.py +13 -2
- tests/client/test_client.py +95 -0
- tests/contrib/test_bulk_tool_caller.py +1 -2
- tests/resources/test_file_resources.py +3 -2
- tests/resources/test_function_resources.py +1 -1
- tests/resources/test_resource_manager.py +85 -1
- tests/resources/test_resource_template.py +0 -15
- tests/server/test_server_interactions.py +24 -24
- tests/tools/test_tool.py +1 -1
- tests/tools/test_tool_manager.py +65 -1
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 |
-
|
|
|
|
|
|
|
| 407 |
|
| 408 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
|
| 253 |
-
```python
|
| 254 |
@mcp.tool()
|
| 255 |
def divide(a: float, b: float) -> float:
|
| 256 |
"""Divide a by b."""
|
| 257 |
-
|
| 258 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 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 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 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
|
| 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
|
| 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
|
| 197 |
-
raise
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 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 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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(
|
| 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(
|
| 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="
|
| 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
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 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="
|
| 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="
|
| 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="
|
| 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="
|
| 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="
|
| 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="
|
| 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="
|
| 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
|
| 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)
|