Skip to content

Commit e2b6036

Browse files
Christian-kamChristian Kamwangala
andauthored
feat(mcp): add support for MCP elicitation -32042 error handling (#1745)
Co-authored-by: Christian Kamwangala <kamwang@amazon.fr>
1 parent 194c69b commit e2b6036

2 files changed

Lines changed: 177 additions & 1 deletion

File tree

src/strands/tools/mcp/mcp_client.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import asyncio
1111
import base64
1212
import contextvars
13+
import json
1314
import logging
1415
import threading
1516
import uuid
@@ -24,8 +25,10 @@
2425
import anyio
2526
from mcp import ClientSession, ListToolsResult
2627
from mcp.client.session import ElicitationFnT
28+
from mcp.shared.exceptions import McpError
2729
from mcp.types import (
2830
BlobResourceContents,
31+
ElicitationRequiredErrorData,
2932
GetPromptResult,
3033
ListPromptsResult,
3134
ListResourcesResult,
@@ -668,7 +671,31 @@ async def call_tool_async(
668671
return self._handle_tool_execution_error(tool_use_id, e)
669672

670673
def _handle_tool_execution_error(self, tool_use_id: str, exception: Exception) -> MCPToolResult:
671-
"""Create error ToolResult with consistent logging."""
674+
"""Create error ToolResult with consistent logging and elicitation callback support.
675+
676+
Args:
677+
tool_use_id: Unique identifier for this tool use.
678+
exception: The exception that occurred during tool execution.
679+
680+
Returns:
681+
MCPToolResult: Error result containing either the elicitation data or the
682+
original exception message.
683+
"""
684+
if isinstance(exception, McpError) and exception.error.code == -32042:
685+
try:
686+
error_data = ElicitationRequiredErrorData.model_validate(exception.error.data)
687+
elicitations = [e.model_dump(exclude_none=True) for e in error_data.elicitations]
688+
689+
return MCPToolResult(
690+
status="error",
691+
toolUseId=tool_use_id,
692+
content=[
693+
{"text": (f"MCP Elicitation required: [{str(exception)}] with data {json.dumps(elicitations)}")}
694+
],
695+
)
696+
except Exception:
697+
logger.debug("Failed to parse ElicitationRequiredErrorData from -32042 error", exc_info=True)
698+
672699
return MCPToolResult(
673700
status="error",
674701
toolUseId=tool_use_id,

tests/strands/tools/mcp/test_mcp_client.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,3 +928,152 @@ async def test_handle_error_message_with_percent_in_message():
928928

929929
# This should not raise TypeError and should not raise the exception (since it's non-fatal)
930930
await client._handle_error_message(error_with_percent)
931+
932+
933+
def test_call_tool_sync_elicitation_error(mock_transport, mock_session):
934+
"""Test that call_tool_sync correctly handles elicitation required errors."""
935+
from mcp.shared.exceptions import McpError
936+
from mcp.types import ElicitationRequiredErrorData, ElicitRequestURLParams
937+
938+
elicitation_data = ElicitationRequiredErrorData(
939+
elicitations=[
940+
ElicitRequestURLParams(
941+
url="https://example.com/auth", message="Please authorize the application", elicitationId="elicit-123"
942+
)
943+
]
944+
)
945+
946+
error = McpError(error=MagicMock(code=-32042, data=elicitation_data.model_dump()))
947+
mock_session.call_tool.side_effect = error
948+
949+
with MCPClient(mock_transport["transport_callable"]) as client:
950+
result = client.call_tool_sync(tool_use_id="test-123", name="test_tool", arguments={"param": "value"})
951+
952+
assert result["status"] == "error"
953+
assert result["toolUseId"] == "test-123"
954+
assert len(result["content"]) == 1
955+
assert "MCP Elicitation required" in result["content"][0]["text"]
956+
assert "https://example.com/auth" in result["content"][0]["text"]
957+
assert "Please authorize the application" in result["content"][0]["text"]
958+
assert "elicit-123" in result["content"][0]["text"]
959+
960+
961+
def test_call_tool_sync_elicitation_error_multiple_urls(mock_transport, mock_session):
962+
"""Test that call_tool_sync correctly handles elicitation errors with multiple elicitations."""
963+
from mcp.shared.exceptions import McpError
964+
from mcp.types import ElicitationRequiredErrorData, ElicitRequestURLParams
965+
966+
elicitation_data = ElicitationRequiredErrorData(
967+
elicitations=[
968+
ElicitRequestURLParams(
969+
url="https://example.com/auth1", message="First authorization", elicitationId="elicit-1"
970+
),
971+
ElicitRequestURLParams(
972+
url="https://example.com/auth2", message="Second authorization", elicitationId="elicit-2"
973+
),
974+
]
975+
)
976+
977+
error = McpError(error=MagicMock(code=-32042, data=elicitation_data.model_dump()))
978+
mock_session.call_tool.side_effect = error
979+
980+
with MCPClient(mock_transport["transport_callable"]) as client:
981+
result = client.call_tool_sync(tool_use_id="test-123", name="test_tool", arguments={"param": "value"})
982+
983+
assert result["status"] == "error"
984+
assert result["toolUseId"] == "test-123"
985+
assert len(result["content"]) == 1
986+
assert "MCP Elicitation required" in result["content"][0]["text"]
987+
assert "https://example.com/auth1" in result["content"][0]["text"]
988+
assert "https://example.com/auth2" in result["content"][0]["text"]
989+
assert "First authorization" in result["content"][0]["text"]
990+
assert "Second authorization" in result["content"][0]["text"]
991+
assert "elicit-1" in result["content"][0]["text"]
992+
assert "elicit-2" in result["content"][0]["text"]
993+
994+
995+
def test_call_tool_sync_elicitation_error_no_urls(mock_transport, mock_session):
996+
"""Test that -32042 error with empty URL still returns generic elicitation result."""
997+
from mcp.shared.exceptions import McpError
998+
from mcp.types import ElicitationRequiredErrorData, ElicitRequestURLParams
999+
1000+
elicitation_data = ElicitationRequiredErrorData(
1001+
elicitations=[ElicitRequestURLParams(url="", message="No URL provided", elicitationId="elicit-1")]
1002+
)
1003+
error = McpError(error=MagicMock(code=-32042, data=elicitation_data.model_dump()))
1004+
mock_session.call_tool.side_effect = error
1005+
1006+
with MCPClient(mock_transport["transport_callable"]) as client:
1007+
result = client.call_tool_sync(tool_use_id="test-123", name="test_tool", arguments={})
1008+
assert result["status"] == "error"
1009+
assert "MCP Elicitation required" in result["content"][0]["text"]
1010+
assert "elicit-1" in result["content"][0]["text"]
1011+
assert "No URL provided" in result["content"][0]["text"]
1012+
1013+
1014+
def test_call_tool_sync_other_mcp_error_code(mock_transport, mock_session):
1015+
"""Test that non-32042 McpError falls through to generic error."""
1016+
from mcp.shared.exceptions import McpError
1017+
1018+
error = McpError(error=MagicMock(code=-32600, message="Invalid request"))
1019+
mock_session.call_tool.side_effect = error
1020+
1021+
with MCPClient(mock_transport["transport_callable"]) as client:
1022+
result = client.call_tool_sync(tool_use_id="test-123", name="test_tool", arguments={})
1023+
assert result["status"] == "error"
1024+
assert "Tool execution failed" in result["content"][0]["text"]
1025+
1026+
1027+
def test_call_tool_sync_elicitation_error_malformed_data(mock_transport, mock_session):
1028+
"""Test that -32042 with unparseable data falls through to generic error."""
1029+
from mcp.shared.exceptions import McpError
1030+
1031+
error = McpError(error=MagicMock(code=-32042, data={"garbage": True}))
1032+
mock_session.call_tool.side_effect = error
1033+
1034+
with MCPClient(mock_transport["transport_callable"]) as client:
1035+
result = client.call_tool_sync(tool_use_id="test-123", name="test_tool", arguments={})
1036+
assert result["status"] == "error"
1037+
assert "Tool execution failed" in result["content"][0]["text"]
1038+
1039+
1040+
@pytest.mark.asyncio
1041+
async def test_call_tool_async_elicitation_error(mock_transport, mock_session):
1042+
"""Test that call_tool_async correctly handles elicitation required errors."""
1043+
from mcp.shared.exceptions import McpError
1044+
from mcp.types import ElicitationRequiredErrorData, ElicitRequestURLParams
1045+
1046+
elicitation_data = ElicitationRequiredErrorData(
1047+
elicitations=[
1048+
ElicitRequestURLParams(
1049+
url="https://example.com/auth", message="Please authorize the application", elicitationId="elicit-123"
1050+
)
1051+
]
1052+
)
1053+
1054+
error = McpError(error=MagicMock(code=-32042, data=elicitation_data.model_dump()))
1055+
1056+
with MCPClient(mock_transport["transport_callable"]) as client:
1057+
with (
1058+
patch("asyncio.run_coroutine_threadsafe") as mock_run_coroutine_threadsafe,
1059+
patch("asyncio.wrap_future") as mock_wrap_future,
1060+
):
1061+
mock_future = MagicMock()
1062+
mock_run_coroutine_threadsafe.return_value = mock_future
1063+
1064+
async def mock_awaitable():
1065+
raise error
1066+
1067+
mock_wrap_future.return_value = mock_awaitable()
1068+
1069+
result = await client.call_tool_async(
1070+
tool_use_id="test-123", name="test_tool", arguments={"param": "value"}
1071+
)
1072+
1073+
assert result["status"] == "error"
1074+
assert result["toolUseId"] == "test-123"
1075+
assert len(result["content"]) == 1
1076+
assert "MCP Elicitation required" in result["content"][0]["text"]
1077+
assert "https://example.com/auth" in result["content"][0]["text"]
1078+
assert "Please authorize the application" in result["content"][0]["text"]
1079+
assert "elicit-123" in result["content"][0]["text"]

0 commit comments

Comments
 (0)