Skip to content

Commit 23bdbab

Browse files
committed
fix: propagate McpError as JSON-RPC error in lowlevel server
When a tool handler raises McpError, the call_tool decorator handler caught it via the generic `except Exception` block and wrapped it as CallToolResult(isError=True), dropping the structured error code on the wire. The UrlElicitationRequiredError subclass already had a re-raise bypass for exactly this reason, but the parent McpError class did not. Broaden the re-raise guard from UrlElicitationRequiredError to McpError so any McpError propagates to _handle_request, which converts it to a JSON-RPC error response preserving the original error code, message, and data. This subsumes the existing UrlElicitationRequiredError bypass since it is a subclass of McpError. Github-Issue:#2770 Reported-by:mengyunxie
1 parent ba33472 commit 23bdbab

2 files changed

Lines changed: 55 additions & 5 deletions

File tree

src/mcp/server/lowlevel/server.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ async def main():
9090
from mcp.server.models import InitializationOptions
9191
from mcp.server.session import ServerSession
9292
from mcp.shared.context import RequestContext
93-
from mcp.shared.exceptions import McpError, UrlElicitationRequiredError
93+
from mcp.shared.exceptions import McpError
9494
from mcp.shared.message import ServerMessageMetadata, SessionMessage
9595
from mcp.shared.session import RequestResponder
9696
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
@@ -582,9 +582,11 @@ async def handler(req: types.CallToolRequest):
582582
isError=False,
583583
)
584584
)
585-
except UrlElicitationRequiredError:
586-
# Re-raise UrlElicitationRequiredError so it can be properly handled
587-
# by _handle_request, which converts it to an error response with code -32042
585+
except McpError:
586+
# Re-raise McpError (including subclasses such as
587+
# UrlElicitationRequiredError) so it can be properly handled by
588+
# _handle_request, which converts it to a JSON-RPC error response
589+
# preserving the structured error code.
588590
raise
589591
except Exception as e:
590592
return self._make_error_result(str(e))
@@ -774,7 +776,7 @@ async def _handle_request(
774776
)
775777
)
776778
response = await handler(req)
777-
except McpError as err: # pragma: no cover
779+
except McpError as err:
778780
response = err.error
779781
except anyio.get_cancelled_exc_class():
780782
if message.cancelled:

tests/server/test_lowlevel_exception_handling.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
from typing import Any
12
from unittest.mock import AsyncMock, Mock
23

34
import pytest
45

56
import mcp.types as types
67
from mcp.server.lowlevel.server import Server
78
from mcp.server.session import ServerSession
9+
from mcp.shared.exceptions import McpError
10+
from mcp.shared.memory import create_connected_server_and_client_session
811
from mcp.shared.session import RequestResponder
912

1013

@@ -72,3 +75,48 @@ async def test_normal_message_handling_not_affected():
7275

7376
# Verify _handle_request was called
7477
server._handle_request.assert_called_once()
78+
79+
80+
@pytest.mark.anyio
81+
async def test_mcp_error_propagates_as_jsonrpc_error():
82+
"""Test that McpError raised in a tool handler propagates as a JSON-RPC error.
83+
84+
The structured error code must be preserved on the wire instead of being
85+
swallowed into a CallToolResult with isError=True.
86+
"""
87+
server = Server("test-server")
88+
89+
@server.call_tool()
90+
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
91+
raise McpError(types.ErrorData(code=-32000, message="server fault", data={"reason": "demo"}))
92+
93+
async with create_connected_server_and_client_session(server) as client_session:
94+
await client_session.initialize()
95+
96+
with pytest.raises(McpError) as exc_info:
97+
await client_session.call_tool("faulty_tool", {})
98+
99+
error = exc_info.value.error
100+
assert error.code == -32000
101+
assert error.message == "server fault"
102+
assert error.data == {"reason": "demo"}
103+
104+
105+
@pytest.mark.anyio
106+
async def test_generic_exception_still_returns_error_result():
107+
"""Test that non-McpError exceptions are still returned as isError=True results."""
108+
server = Server("test-server")
109+
110+
@server.call_tool()
111+
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
112+
raise ValueError("Something went wrong")
113+
114+
async with create_connected_server_and_client_session(server) as client_session:
115+
await client_session.initialize()
116+
117+
result = await client_session.call_tool("failing_tool", {})
118+
119+
assert result.isError is True
120+
assert len(result.content) == 1
121+
assert isinstance(result.content[0], types.TextContent)
122+
assert "Something went wrong" in result.content[0].text

0 commit comments

Comments
 (0)