Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ async def main():
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.shared.context import RequestContext
from mcp.shared.exceptions import McpError, UrlElicitationRequiredError
from mcp.shared.exceptions import McpError
from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.session import RequestResponder
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
Expand Down Expand Up @@ -582,9 +582,11 @@ async def handler(req: types.CallToolRequest):
isError=False,
)
)
except UrlElicitationRequiredError:
# Re-raise UrlElicitationRequiredError so it can be properly handled
# by _handle_request, which converts it to an error response with code -32042
except McpError:
# Re-raise McpError (including subclasses such as
# UrlElicitationRequiredError) so it can be properly handled by
# _handle_request, which converts it to a JSON-RPC error response
# preserving the structured error code.
raise
except Exception as e:
return self._make_error_result(str(e))
Expand Down Expand Up @@ -774,7 +776,7 @@ async def _handle_request(
)
)
response = await handler(req)
except McpError as err: # pragma: no cover
except McpError as err:
response = err.error
except anyio.get_cancelled_exc_class():
if message.cancelled:
Expand Down
48 changes: 48 additions & 0 deletions tests/server/test_lowlevel_exception_handling.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from typing import Any
from unittest.mock import AsyncMock, Mock

import pytest

import mcp.types as types
from mcp.server.lowlevel.server import Server
from mcp.server.session import ServerSession
from mcp.shared.exceptions import McpError
from mcp.shared.memory import create_connected_server_and_client_session
from mcp.shared.session import RequestResponder


Expand Down Expand Up @@ -72,3 +75,48 @@ async def test_normal_message_handling_not_affected():

# Verify _handle_request was called
server._handle_request.assert_called_once()


@pytest.mark.anyio
async def test_mcp_error_propagates_as_jsonrpc_error():
"""Test that McpError raised in a tool handler propagates as a JSON-RPC error.

The structured error code must be preserved on the wire instead of being
swallowed into a CallToolResult with isError=True.
"""
server = Server("test-server")

@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
raise McpError(types.ErrorData(code=-32000, message="server fault", data={"reason": "demo"}))

async with create_connected_server_and_client_session(server) as client_session:
await client_session.initialize()

with pytest.raises(McpError) as exc_info:
await client_session.call_tool("faulty_tool", {})

error = exc_info.value.error
assert error.code == -32000
assert error.message == "server fault"
assert error.data == {"reason": "demo"}


@pytest.mark.anyio
async def test_generic_exception_still_returns_error_result():
"""Test that non-McpError exceptions are still returned as isError=True results."""
server = Server("test-server")

@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
raise ValueError("Something went wrong")

async with create_connected_server_and_client_session(server) as client_session:
await client_session.initialize()

result = await client_session.call_tool("failing_tool", {})

assert result.isError is True
assert len(result.content) == 1
assert isinstance(result.content[0], types.TextContent)
assert "Something went wrong" in result.content[0].text
Loading