Skip to content

Commit 012f674

Browse files
Fix raise_on_error handling for tool tasks (#3946)
1 parent 7dd5739 commit 012f674

3 files changed

Lines changed: 104 additions & 5 deletions

File tree

src/fastmcp/client/mixins/tools.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,12 @@ async def call_tool(
288288

289289
if task:
290290
return await self._call_tool_as_task(
291-
name, arguments, task_id, ttl, meta=request_meta or None
291+
name,
292+
arguments,
293+
task_id,
294+
ttl,
295+
raise_on_error=raise_on_error,
296+
meta=request_meta or None,
292297
)
293298

294299
result = await self.call_tool_mcp(
@@ -308,6 +313,7 @@ async def _call_tool_as_task(
308313
arguments: dict[str, Any] | None = None,
309314
task_id: str | None = None,
310315
ttl: int = 60000,
316+
raise_on_error: bool = True,
311317
meta: dict[str, Any] | None = None,
312318
) -> ToolTask:
313319
"""Call a tool for background execution (SEP-1686).
@@ -321,6 +327,7 @@ async def _call_tool_as_task(
321327
arguments: Tool arguments
322328
task_id: Optional client-provided task ID (ignored, for backward compatibility)
323329
ttl: Time to keep results available in milliseconds (default 60s)
330+
raise_on_error: Whether task.result() should raise ToolError on errors
324331
meta: Optional request metadata (e.g., version info)
325332
326333
Returns:
@@ -356,7 +363,11 @@ async def _call_tool_as_task(
356363
self._submitted_task_ids.add(server_task_id)
357364

358365
task_obj = ToolTask(
359-
self, server_task_id, tool_name=name, immediate_result=None
366+
self,
367+
server_task_id,
368+
tool_name=name,
369+
immediate_result=None,
370+
raise_on_error=raise_on_error,
360371
)
361372
self._task_registry[server_task_id] = weakref.ref(task_obj)
362373
return task_obj
@@ -369,6 +380,7 @@ async def _call_tool_as_task(
369380
synthetic_task_id,
370381
tool_name=name,
371382
immediate_result=parsed_result,
383+
raise_on_error=raise_on_error,
372384
)
373385

374386

src/fastmcp/client/tasks.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from mcp.types import GetTaskResult, TaskStatusNotification
1616

1717
from fastmcp.client.messages import Message, MessageHandler
18+
from fastmcp.exceptions import ToolError
1819
from fastmcp.utilities.logging import get_logger
1920

2021
logger = get_logger(__name__)
@@ -335,6 +336,7 @@ def __init__(
335336
task_id: str,
336337
tool_name: str,
337338
immediate_result: CallToolResult | None = None,
339+
raise_on_error: bool = True,
338340
):
339341
"""
340342
Create a ToolTask wrapper.
@@ -344,9 +346,11 @@ def __init__(
344346
task_id: The task identifier
345347
tool_name: Name of the tool being executed
346348
immediate_result: If server executed synchronously, the immediate result
349+
raise_on_error: Whether task.result() should raise ToolError on errors
347350
"""
348351
super().__init__(client, task_id, immediate_result)
349352
self._tool_name = tool_name
353+
self._raise_on_error = raise_on_error
350354

351355
async def result(self) -> CallToolResult:
352356
"""Wait for and return the tool result.
@@ -364,6 +368,14 @@ async def result(self) -> CallToolResult:
364368
if self._is_immediate:
365369
assert self._immediate_result is not None # Type narrowing
366370
result = self._immediate_result
371+
if result.is_error and self._raise_on_error:
372+
if result.content and isinstance(
373+
result.content[0], mcp.types.TextContent
374+
):
375+
msg = result.content[0].text
376+
else:
377+
msg = f"Tool '{self._tool_name}' returned an error"
378+
raise ToolError(msg)
367379
else:
368380
# Check client connected
369381
self._check_client_connected()
@@ -379,12 +391,16 @@ async def result(self) -> CallToolResult:
379391
# Raw dict from get_task_result - parse as CallToolResult
380392
mcp_result = mcp.types.CallToolResult.model_validate(raw_result)
381393
result = await self._client._parse_call_tool_result(
382-
self._tool_name, mcp_result, raise_on_error=True
394+
self._tool_name,
395+
mcp_result,
396+
raise_on_error=self._raise_on_error,
383397
)
384398
elif isinstance(raw_result, mcp.types.CallToolResult):
385399
# Already a CallToolResult from MCP protocol - parse it
386400
result = await self._client._parse_call_tool_result(
387-
self._tool_name, raw_result, raise_on_error=True
401+
self._tool_name,
402+
raw_result,
403+
raise_on_error=self._raise_on_error,
388404
)
389405
else:
390406
# Legacy ToolResult format - convert to MCP type
@@ -397,7 +413,9 @@ async def result(self) -> CallToolResult:
397413
_meta=raw_result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument]
398414
)
399415
result = await self._client._parse_call_tool_result(
400-
self._tool_name, mcp_result, raise_on_error=True
416+
self._tool_name,
417+
mcp_result,
418+
raise_on_error=self._raise_on_error,
401419
)
402420
else:
403421
# Unknown type - just return it

tests/client/tasks/test_client_tool_tasks.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from fastmcp import FastMCP
1111
from fastmcp.client import Client
1212
from fastmcp.client.tasks import ToolTask
13+
from fastmcp.exceptions import ToolError
1314

1415

1516
@pytest.fixture
@@ -85,3 +86,71 @@ async def test_tool_task_status_and_wait(tool_task_server):
8586
await task.wait(timeout=2.0)
8687
final_status = await task.status()
8788
assert final_status.status == "completed"
89+
90+
91+
async def test_immediate_tool_task_respects_raise_on_error_true():
92+
"""Immediate task fallback should still raise ToolError when requested."""
93+
mcp = FastMCP("immediate-tool-task-error")
94+
95+
@mcp.tool
96+
def failing_tool() -> str:
97+
raise ValueError("immediate task failure")
98+
99+
async with Client(mcp) as client:
100+
task = await client.call_tool("failing_tool", task=True, raise_on_error=True)
101+
102+
assert task.returned_immediately
103+
with pytest.raises(
104+
ToolError, match="does not support task-augmented execution"
105+
):
106+
await task.result()
107+
108+
109+
async def test_immediate_tool_task_respects_raise_on_error_false():
110+
"""Immediate task fallback should return error results when requested."""
111+
mcp = FastMCP("immediate-tool-task-no-raise")
112+
113+
@mcp.tool
114+
def failing_tool() -> str:
115+
raise ValueError("immediate task failure")
116+
117+
async with Client(mcp) as client:
118+
task = await client.call_tool("failing_tool", task=True, raise_on_error=False)
119+
120+
assert task.returned_immediately
121+
result = await task.result()
122+
assert result.is_error is True
123+
assert "does not support task-augmented execution" in str(result)
124+
125+
126+
async def test_background_tool_task_respects_raise_on_error_true():
127+
"""Background tasks should still raise ToolError by default on errors."""
128+
mcp = FastMCP("background-tool-task-error")
129+
130+
@mcp.tool(task=True)
131+
async def failing_tool() -> str:
132+
raise ValueError("background task failure")
133+
134+
async with Client(mcp) as client:
135+
task = await client.call_tool("failing_tool", task=True, raise_on_error=True)
136+
137+
assert not task.returned_immediately
138+
with pytest.raises(ToolError, match="background task failure"):
139+
await task.result()
140+
141+
142+
async def test_background_tool_task_respects_raise_on_error_false():
143+
"""Background tasks should return error results when raise_on_error is disabled."""
144+
mcp = FastMCP("background-tool-task-no-raise")
145+
146+
@mcp.tool(task=True)
147+
async def failing_tool() -> str:
148+
raise ValueError("background task failure")
149+
150+
async with Client(mcp) as client:
151+
task = await client.call_tool("failing_tool", task=True, raise_on_error=False)
152+
153+
assert not task.returned_immediately
154+
result = await task.result()
155+
assert result.is_error is True
156+
assert "background task failure" in str(result)

0 commit comments

Comments
 (0)