Skip to content

Commit 343f539

Browse files
committed
fix(server): hide unexpected tool exception details
1 parent cf110e3 commit 343f539

8 files changed

Lines changed: 51 additions & 21 deletions

File tree

src/mcp/server/mcpserver/tools/base.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@
44
from functools import cached_property
55
from typing import TYPE_CHECKING, Any
66

7+
import pydantic_core
78
from pydantic import BaseModel, Field
89

910
from mcp.server.mcpserver.exceptions import ToolError
1011
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
1112
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
13+
from mcp.server.mcpserver.utilities.logging import get_logger
1214
from mcp.shared._callable_inspection import is_async_callable
1315
from mcp.shared.exceptions import UrlElicitationRequiredError
1416
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
1517
from mcp.types import Icon, ToolAnnotations
1618

19+
logger = get_logger(__name__)
20+
1721
if TYPE_CHECKING:
1822
from mcp.server.context import LifespanContextT, RequestT
1923
from mcp.server.mcpserver.context import Context
@@ -115,5 +119,10 @@ async def run(
115119
# Re-raise UrlElicitationRequiredError so it can be properly handled
116120
# as an MCP error response with code -32042
117121
raise
118-
except Exception as e:
122+
except ToolError:
123+
raise
124+
except pydantic_core.ValidationError as e:
119125
raise ToolError(f"Error executing tool {self.name}: {e}") from e
126+
except Exception as e:
127+
logger.exception("Unexpected error executing tool %s", self.name)
128+
raise ToolError(f"Error executing tool {self.name}: unexpected internal error") from e

tests/client/test_list_roots_callback.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,4 @@ async def test_list_roots(context: Context, message: str):
4444
result = await client.call_tool("test_list_roots", {"message": "test message"})
4545
assert result.is_error is True
4646
assert isinstance(result.content[0], TextContent)
47-
assert result.content[0].text == "Error executing tool test_list_roots: List roots not supported"
47+
assert result.content[0].text == "Error executing tool test_list_roots: unexpected internal error"

tests/client/test_sampling_callback.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ async def test_sampling_tool(message: str, ctx: Context) -> bool:
5454
result = await client.call_tool("test_sampling", {"message": "Test message for sampling"})
5555
assert result.is_error is True
5656
assert isinstance(result.content[0], TextContent)
57-
assert result.content[0].text == "Error executing tool test_sampling: Sampling not supported"
57+
assert result.content[0].text == "Error executing tool test_sampling: unexpected internal error"
5858

5959

6060
@pytest.mark.anyio

tests/interaction/_requirements.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -677,8 +677,8 @@ def __post_init__(self) -> None:
677677
"mcpserver:tool:handler-throws": Requirement(
678678
source="sdk",
679679
behavior=(
680-
"An exception raised by a tool function (ToolError or otherwise) is caught and returned as a "
681-
"tool result with isError true and the failure text in content; it does not become a JSON-RPC error."
680+
"An exception raised by a tool function is caught and returned as a tool result with isError true; "
681+
"explicit ToolError messages are returned to the client, while unexpected exception details are hidden."
682682
),
683683
),
684684
"mcpserver:tool:input-validation": Requirement(

tests/interaction/mcpserver/test_tools.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,19 +81,22 @@ async def test_call_tool_function_exception_becomes_error_result(connect: Connec
8181
8282
The function's `-> str` annotation gives the tool a derived output schema, but the error
8383
result is built before any schema validation runs, so no validation failure is layered on
84-
top of the original exception.
84+
top of the original exception. Unexpected exception details are logged server-side and
85+
hidden from the client-facing result.
8586
"""
8687
mcp = MCPServer("errors")
8788

8889
@mcp.tool()
8990
def explode() -> str:
90-
raise ValueError("boom")
91+
raise ValueError("secret token leaked")
9192

9293
async with connect(mcp) as client:
9394
result = await client.call_tool("explode", {})
9495

9596
assert result == snapshot(
96-
CallToolResult(content=[TextContent(text="Error executing tool explode: boom")], is_error=True)
97+
CallToolResult(
98+
content=[TextContent(text="Error executing tool explode: unexpected internal error")], is_error=True
99+
)
97100
)
98101

99102

@@ -109,9 +112,7 @@ def flux() -> str:
109112
async with connect(mcp) as client:
110113
result = await client.call_tool("flux", {})
111114

112-
assert result == snapshot(
113-
CallToolResult(content=[TextContent(text="Error executing tool flux: flux capacitor offline")], is_error=True)
114-
)
115+
assert result == snapshot(CallToolResult(content=[TextContent(text="flux capacitor offline")], is_error=True))
115116

116117

117118
@requirement("mcpserver:tool:unknown-name")

tests/server/mcpserver/test_server.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,8 @@ async def test_tool_exception_handling(self):
265265
assert len(result.content) == 1
266266
content = result.content[0]
267267
assert isinstance(content, TextContent)
268-
assert "Test error" in content.text
268+
assert content.text == "Error executing tool error_tool_fn: unexpected internal error"
269+
assert "Test error" not in content.text
269270
assert result.is_error is True
270271

271272
async def test_tool_error_handling(self):
@@ -276,19 +277,21 @@ async def test_tool_error_handling(self):
276277
assert len(result.content) == 1
277278
content = result.content[0]
278279
assert isinstance(content, TextContent)
279-
assert "Test error" in content.text
280+
assert content.text == "Error executing tool error_tool_fn: unexpected internal error"
281+
assert "Test error" not in content.text
280282
assert result.is_error is True
281283

282284
async def test_tool_error_details(self):
283-
"""Test that exception details are properly formatted in the response"""
285+
"""Test that unexpected exception details are hidden in the response."""
284286
mcp = MCPServer()
285287
mcp.add_tool(error_tool_fn)
286288
async with Client(mcp) as client:
287289
result = await client.call_tool("error_tool_fn", {})
288290
content = result.content[0]
289291
assert isinstance(content, TextContent)
290292
assert isinstance(content.text, str)
291-
assert "Test error" in content.text
293+
assert content.text == "Error executing tool error_tool_fn: unexpected internal error"
294+
assert "Test error" not in content.text
292295
assert result.is_error is True
293296

294297
async def test_tool_return_value_conversion(self):

tests/server/mcpserver/test_tool_manager.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -383,18 +383,34 @@ async def async_tool(x: int, ctx: Context) -> str:
383383
assert result == "42"
384384

385385
@pytest.mark.anyio
386-
async def test_context_error_handling(self):
387-
"""Test error handling when context injection fails."""
386+
async def test_unexpected_error_hides_internal_details(self):
387+
"""Test error handling does not expose unexpected exception details."""
388388

389389
def tool_with_context(x: int, ctx: Context) -> str:
390-
raise ValueError("Test error")
390+
raise ValueError("secret token leaked")
391391

392392
manager = ToolManager()
393393
manager.add_tool(tool_with_context)
394394

395-
with pytest.raises(ToolError, match="Error executing tool tool_with_context"):
395+
with pytest.raises(ToolError) as exc_info:
396396
await manager.call_tool("tool_with_context", {"x": 42}, context=Context())
397397

398+
assert str(exc_info.value) == "Error executing tool tool_with_context: unexpected internal error"
399+
assert "secret token leaked" not in str(exc_info.value)
400+
401+
@pytest.mark.anyio
402+
async def test_tool_error_preserves_message(self):
403+
"""Test explicit ToolError messages remain visible to clients."""
404+
405+
def tool_with_expected_error() -> str:
406+
raise ToolError("safe client-facing error")
407+
408+
manager = ToolManager()
409+
manager.add_tool(tool_with_expected_error)
410+
411+
with pytest.raises(ToolError, match="safe client-facing error"):
412+
await manager.call_tool("tool_with_expected_error", {}, context=Context())
413+
398414

399415
class TestToolAnnotations:
400416
def test_tool_annotations(self):

tests/server/mcpserver/test_url_elicitation_error_throw.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ async def multi_auth(ctx: Context) -> str:
9292

9393
@pytest.mark.anyio
9494
async def test_normal_exceptions_still_return_error_result():
95-
"""Test that normal exceptions still return CallToolResult with is_error=True."""
95+
"""Test that normal exceptions still return CallToolResult without exposing details."""
9696
mcp = MCPServer(name="NormalErrorServer")
9797

9898
@mcp.tool(description="A tool that raises a normal exception")
@@ -105,4 +105,5 @@ async def failing_tool(ctx: Context) -> str:
105105
assert result.is_error is True
106106
assert len(result.content) == 1
107107
assert isinstance(result.content[0], types.TextContent)
108-
assert "Something went wrong" in result.content[0].text
108+
assert result.content[0].text == "Error executing tool failing_tool: unexpected internal error"
109+
assert "Something went wrong" not in result.content[0].text

0 commit comments

Comments
 (0)