|
1 | 1 | """Client error handling tests.""" |
2 | 2 |
|
| 3 | +import logging |
| 4 | + |
3 | 5 | import mcp.types |
4 | 6 | import pytest |
5 | 7 | from mcp.types import TextContent |
|
8 | 10 | from fastmcp.client import Client |
9 | 11 | from fastmcp.client.mixins.tools import _parse_call_tool_result |
10 | 12 | from fastmcp.client.transports import FastMCPTransport |
11 | | -from fastmcp.exceptions import ResourceError, ToolError |
| 13 | +from fastmcp.exceptions import PromptError, ResourceError, ToolError |
12 | 14 | from fastmcp.server.server import FastMCP |
13 | 15 |
|
14 | 16 |
|
@@ -265,3 +267,161 @@ async def test_error_with_structured_content_does_not_parse_data(self): |
265 | 267 | assert parsed.is_error is True |
266 | 268 | assert parsed.data is None |
267 | 269 | assert parsed.structured_content == {"key": "value"} |
| 270 | + |
| 271 | + |
| 272 | +class TestSuppressLog: |
| 273 | + async def test_tool_error_with_suppress_log_does_not_log(self, caplog): |
| 274 | + """ToolError with suppress_log=True should skip logger.exception().""" |
| 275 | + mcp = FastMCP("TestServer") |
| 276 | + |
| 277 | + @mcp.tool |
| 278 | + def suppressed_error_tool(): |
| 279 | + raise ToolError("Missing required parameter", suppress_log=True) |
| 280 | + |
| 281 | + async with Client(transport=FastMCPTransport(mcp)) as client: |
| 282 | + with caplog.at_level(logging.ERROR): |
| 283 | + result = await client.call_tool_mcp("suppressed_error_tool", {}) |
| 284 | + |
| 285 | + assert result.isError |
| 286 | + assert isinstance(result.content[0], TextContent) |
| 287 | + assert "Missing required parameter" in result.content[0].text |
| 288 | + assert not any( |
| 289 | + "Error calling tool" in record.message and record.levelname == "ERROR" |
| 290 | + for record in caplog.records |
| 291 | + ) |
| 292 | + |
| 293 | + async def test_regular_tool_error_still_logs(self, caplog): |
| 294 | + """ToolError without suppress_log still triggers logger.exception().""" |
| 295 | + mcp = FastMCP("TestServer") |
| 296 | + |
| 297 | + @mcp.tool |
| 298 | + def regular_error_tool(): |
| 299 | + raise ToolError("Something went wrong") |
| 300 | + |
| 301 | + async with Client(transport=FastMCPTransport(mcp)) as client: |
| 302 | + with caplog.at_level(logging.ERROR): |
| 303 | + result = await client.call_tool_mcp("regular_error_tool", {}) |
| 304 | + |
| 305 | + assert result.isError |
| 306 | + assert isinstance(result.content[0], TextContent) |
| 307 | + assert "Something went wrong" in result.content[0].text |
| 308 | + assert any( |
| 309 | + "Error calling tool 'regular_error_tool'" in record.message |
| 310 | + and record.levelname == "ERROR" |
| 311 | + for record in caplog.records |
| 312 | + ) |
| 313 | + |
| 314 | + async def test_resource_error_with_suppress_log_does_not_log(self, caplog): |
| 315 | + """ResourceError with suppress_log=True should skip logger.exception().""" |
| 316 | + mcp = FastMCP("TestServer") |
| 317 | + |
| 318 | + @mcp.resource("test://suppressed") |
| 319 | + def suppressed_resource(): |
| 320 | + raise ResourceError( |
| 321 | + "Resource unavailable, try again later", suppress_log=True |
| 322 | + ) |
| 323 | + |
| 324 | + async with Client(transport=FastMCPTransport(mcp)) as client: |
| 325 | + with caplog.at_level(logging.ERROR): |
| 326 | + with pytest.raises(Exception) as exc_info: |
| 327 | + await client.read_resource_mcp("test://suppressed") |
| 328 | + |
| 329 | + assert "Resource unavailable, try again later" in str(exc_info.value) |
| 330 | + assert not any( |
| 331 | + "Error reading resource" in record.message and record.levelname == "ERROR" |
| 332 | + for record in caplog.records |
| 333 | + ) |
| 334 | + |
| 335 | + async def test_regular_resource_error_still_logs(self, caplog): |
| 336 | + """ResourceError without suppress_log still triggers logger.exception().""" |
| 337 | + mcp = FastMCP("TestServer") |
| 338 | + |
| 339 | + @mcp.resource("test://regular") |
| 340 | + def regular_resource(): |
| 341 | + raise ResourceError("Something went wrong") |
| 342 | + |
| 343 | + async with Client(transport=FastMCPTransport(mcp)) as client: |
| 344 | + with caplog.at_level(logging.ERROR): |
| 345 | + with pytest.raises(Exception) as exc_info: |
| 346 | + await client.read_resource_mcp("test://regular") |
| 347 | + |
| 348 | + assert "Something went wrong" in str(exc_info.value) |
| 349 | + assert any( |
| 350 | + "Error reading resource 'test://regular'" in record.message |
| 351 | + and record.levelname == "ERROR" |
| 352 | + for record in caplog.records |
| 353 | + ) |
| 354 | + |
| 355 | + async def test_prompt_error_with_suppress_log_does_not_log(self, caplog): |
| 356 | + """PromptError with suppress_log=True should skip logger.exception().""" |
| 357 | + mcp = FastMCP("TestServer") |
| 358 | + |
| 359 | + @mcp.prompt |
| 360 | + def suppressed_prompt(): |
| 361 | + raise PromptError( |
| 362 | + "Insufficient context, provide more details", suppress_log=True |
| 363 | + ) |
| 364 | + |
| 365 | + async with Client(transport=FastMCPTransport(mcp)) as client: |
| 366 | + with caplog.at_level(logging.ERROR): |
| 367 | + with pytest.raises(Exception) as exc_info: |
| 368 | + await client.get_prompt("suppressed_prompt") |
| 369 | + |
| 370 | + assert "Insufficient context" in str(exc_info.value) |
| 371 | + assert not any( |
| 372 | + "Error rendering prompt" in record.message and record.levelname == "ERROR" |
| 373 | + for record in caplog.records |
| 374 | + ) |
| 375 | + |
| 376 | + async def test_regular_prompt_error_still_logs(self, caplog): |
| 377 | + """PromptError without suppress_log still triggers logger.exception().""" |
| 378 | + mcp = FastMCP("TestServer") |
| 379 | + |
| 380 | + @mcp.prompt |
| 381 | + def regular_prompt(): |
| 382 | + raise PromptError("Something went wrong") |
| 383 | + |
| 384 | + async with Client(transport=FastMCPTransport(mcp)) as client: |
| 385 | + with caplog.at_level(logging.ERROR): |
| 386 | + with pytest.raises(Exception) as exc_info: |
| 387 | + await client.get_prompt("regular_prompt") |
| 388 | + |
| 389 | + assert "Something went wrong" in str(exc_info.value) |
| 390 | + assert any( |
| 391 | + "Error rendering prompt 'regular_prompt'" in record.message |
| 392 | + and record.levelname == "ERROR" |
| 393 | + for record in caplog.records |
| 394 | + ) |
| 395 | + |
| 396 | + async def test_sampling_tool_error_with_suppress_log_does_not_log(self, caplog): |
| 397 | + """ToolError with suppress_log=True in sampling should skip logging.""" |
| 398 | + from mcp.types import ToolUseContent |
| 399 | + |
| 400 | + from fastmcp.server.sampling.run import SamplingTool, execute_tools |
| 401 | + |
| 402 | + async def suppressed_sampling_tool(x: int) -> int: |
| 403 | + raise ToolError("Expected sampling error", suppress_log=True) |
| 404 | + |
| 405 | + tool = SamplingTool.from_function(suppressed_sampling_tool) |
| 406 | + tool_use = ToolUseContent( |
| 407 | + type="tool_use", |
| 408 | + id="test-id", |
| 409 | + name="suppressed_sampling_tool", |
| 410 | + input={"x": 42}, |
| 411 | + ) |
| 412 | + |
| 413 | + with caplog.at_level(logging.ERROR): |
| 414 | + results = await execute_tools( |
| 415 | + tool_calls=[tool_use], |
| 416 | + tool_map={"suppressed_sampling_tool": tool}, |
| 417 | + mask_error_details=False, |
| 418 | + ) |
| 419 | + |
| 420 | + assert len(results) == 1 |
| 421 | + assert results[0].isError |
| 422 | + assert "Expected sampling error" in results[0].content[0].text # type: ignore |
| 423 | + assert not any( |
| 424 | + "Error calling sampling tool" in record.message |
| 425 | + and record.levelname == "ERROR" |
| 426 | + for record in caplog.records |
| 427 | + ) |
0 commit comments