Skip to content

Commit a8073fc

Browse files
committed
Add suppress_log parameter to FastMCP errors
Add suppress_log parameter to FastMCPError and subclasses to skip server-side logging for expected control-flow errors.
1 parent e95efce commit a8073fc

7 files changed

Lines changed: 187 additions & 14 deletions

File tree

src/fastmcp/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ class FastMCPDeprecationWarning(DeprecationWarning):
1515
class FastMCPError(Exception):
1616
"""Base error for FastMCP."""
1717

18+
def __init__(self, *args: object, suppress_log: bool = False) -> None:
19+
super().__init__(*args)
20+
self.suppress_log = suppress_log
21+
1822

1923
class ValidationError(FastMCPError):
2024
"""Error in validating parameters or return values."""

src/fastmcp/prompts/function_prompt.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
import fastmcp
2626
from fastmcp.decorators import resolve_task_config
27-
from fastmcp.exceptions import FastMCPDeprecationWarning, PromptError
27+
from fastmcp.exceptions import FastMCPDeprecationWarning, FastMCPError, PromptError
2828
from fastmcp.prompts.base import Prompt, PromptArgument, PromptResult
2929
from fastmcp.server.auth.authorization import AuthCheck
3030
from fastmcp.server.dependencies import (
@@ -361,6 +361,8 @@ async def render(
361361
result = await result
362362

363363
return self.convert_result(result)
364+
except FastMCPError:
365+
raise
364366
except Exception as e:
365367
logger.exception(f"Error rendering prompt {self.name}")
366368
raise PromptError(f"Error rendering prompt {self.name!r}: {e}") from e

src/fastmcp/server/sampling/run.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,8 @@ async def _execute_single_tool(tool_use: ToolUseContent) -> ToolResultContent:
304304
)
305305
except ToolError as e:
306306
# ToolError is the escape hatch - always pass message through
307-
logger.exception(f"Error calling sampling tool '{tool_use.name}'")
307+
if not e.suppress_log:
308+
logger.exception(f"Error calling sampling tool '{tool_use.name}'")
308309
return ToolResultContent(
309310
type="tool_result",
310311
toolUseId=tool_use.id,

src/fastmcp/server/server.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,8 +1259,9 @@ async def call_tool(
12591259
task_meta = replace(task_meta, fn_key=tool.key)
12601260
try:
12611261
return await tool._run(arguments or {}, task_meta=task_meta)
1262-
except FastMCPError:
1263-
logger.exception(f"Error calling tool {name!r}")
1262+
except FastMCPError as e:
1263+
if not e.suppress_log:
1264+
logger.exception(f"Error calling tool {name!r}")
12641265
raise
12651266
except (ValidationError, PydanticValidationError):
12661267
logger.exception(f"Error validating tool {name!r}")
@@ -1389,8 +1390,9 @@ async def read_resource(
13891390
task_meta = replace(task_meta, fn_key=resource.key)
13901391
try:
13911392
return await resource._read(task_meta=task_meta)
1392-
except (FastMCPError, McpError):
1393-
logger.exception(f"Error reading resource {uri!r}")
1393+
except (FastMCPError, McpError) as e:
1394+
if not getattr(e, "suppress_log", False):
1395+
logger.exception(f"Error reading resource {uri!r}")
13941396
raise
13951397
except Exception as e:
13961398
logger.exception(f"Error reading resource {uri!r}")
@@ -1428,8 +1430,9 @@ async def read_resource(
14281430
task_meta = replace(task_meta, fn_key=template.key)
14291431
try:
14301432
return await template._read(uri, params, task_meta=task_meta)
1431-
except (FastMCPError, McpError):
1432-
logger.exception(f"Error reading resource {uri!r}")
1433+
except (FastMCPError, McpError) as e:
1434+
if not getattr(e, "suppress_log", False):
1435+
logger.exception(f"Error reading resource {uri!r}")
14331436
raise
14341437
except Exception as e:
14351438
logger.exception(f"Error reading resource {uri!r}")
@@ -1542,8 +1545,9 @@ async def render_prompt(
15421545
task_meta = replace(task_meta, fn_key=prompt.key)
15431546
try:
15441547
return await prompt._render(arguments, task_meta=task_meta)
1545-
except (FastMCPError, McpError):
1546-
logger.exception(f"Error rendering prompt {name!r}")
1548+
except (FastMCPError, McpError) as e:
1549+
if not getattr(e, "suppress_log", False):
1550+
logger.exception(f"Error rendering prompt {name!r}")
15471551
raise
15481552
except Exception as e:
15491553
logger.exception(f"Error rendering prompt {name!r}")

tests/client/client/test_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ def strict_typed_prompt(numbers: list[int]) -> str:
284284
client = Client(transport=FastMCPTransport(server))
285285

286286
async with client:
287-
with pytest.raises(McpError, match="Error rendering prompt"):
287+
with pytest.raises(McpError, match="Could not convert argument"):
288288
await client.get_prompt(
289289
"strict_typed_prompt",
290290
{

tests/client/client/test_error_handling.py

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Client error handling tests."""
22

3+
import logging
4+
35
import mcp.types
46
import pytest
57
from mcp.types import TextContent
@@ -8,7 +10,7 @@
810
from fastmcp.client import Client
911
from fastmcp.client.mixins.tools import _parse_call_tool_result
1012
from fastmcp.client.transports import FastMCPTransport
11-
from fastmcp.exceptions import ResourceError, ToolError
13+
from fastmcp.exceptions import PromptError, ResourceError, ToolError
1214
from fastmcp.server.server import FastMCP
1315

1416

@@ -265,3 +267,161 @@ async def test_error_with_structured_content_does_not_parse_data(self):
265267
assert parsed.is_error is True
266268
assert parsed.data is None
267269
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+
)

tests/prompts/test_prompt.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,11 +273,13 @@ def typed_prompt(numbers: list[int]) -> str:
273273

274274
prompt = Prompt.from_function(typed_prompt)
275275

276-
# Test with invalid JSON - should raise PromptError due to exception handling in render()
276+
# Test with invalid JSON - should raise PromptError with type conversion details
277277
with pytest.raises(PromptError) as exc_info:
278278
await prompt.render(arguments={"numbers": "not valid json"})
279279

280-
assert f"Error rendering prompt {prompt.name!r}" in str(exc_info.value)
280+
# PromptError passes through unchanged
281+
assert "Could not convert argument 'numbers'" in str(exc_info.value)
282+
assert "list[int]" in str(exc_info.value)
281283

282284
async def test_json_parsing_fallback(self):
283285
"""Test that JSON parsing falls back to direct validation when needed."""

0 commit comments

Comments
 (0)