Skip to content

Commit 7231664

Browse files
committed
Re-raise MCPError from @mcp.tool() handlers as a top-level JSON-RPC error
The tool wrapper at Tool.run() was catching MCPError along with all other exceptions and re-wrapping it as ToolError, which _handle_call_tool then flattens into CallToolResult(isError=True). This discards the JSON-RPC error code and structured data, so a tool raising e.g. MISSING_REQUIRED_CLIENT_CAPABILITY couldn't produce a protocol-level error response. UrlElicitationRequiredError was already special-cased; since it subclasses MCPError, generalising the re-raise to MCPError covers both and any future protocol-error subclass.
1 parent 9470abf commit 7231664

3 files changed

Lines changed: 68 additions & 5 deletions

File tree

‎docs/migration.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ If you call `MCPServer.call_tool()` directly, read `.content` and
1919
`.structured_content` off the returned `CallToolResult` instead of branching on
2020
the result type.
2121

22+
### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error
23+
24+
Raising `MCPError` (or a subclass such as `UrlElicitationRequiredError`) inside
25+
an `@mcp.tool()` handler now produces a top-level JSON-RPC error response with
26+
the raised `code`, `message`, and `data` intact. Previously the tool wrapper
27+
caught it like any other exception and returned `CallToolResult(isError=True)`,
28+
which discarded the error code and structured `data`.
29+
30+
`MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it
31+
when the request itself should be rejected (missing client capability,
32+
elicitation required, invalid parameters). For tool *execution* failures the
33+
calling LLM should see and react to, raise any other exception or return
34+
`CallToolResult(is_error=True, ...)` directly; that path is unchanged.
35+
2236
### `streamablehttp_client` removed
2337

2438
The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead.

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
1111
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
1212
from mcp.shared._callable_inspection import is_async_callable
13-
from mcp.shared.exceptions import UrlElicitationRequiredError
13+
from mcp.shared.exceptions import MCPError
1414
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
1515
from mcp.types import Icon, ToolAnnotations
1616

@@ -111,9 +111,12 @@ async def run(
111111
result = self.fn_metadata.convert_result(result)
112112

113113
return result
114-
except UrlElicitationRequiredError:
115-
# Re-raise UrlElicitationRequiredError so it can be properly handled
116-
# as an MCP error response with code -32042
114+
except MCPError:
115+
# `MCPError` (and subclasses such as `UrlElicitationRequiredError`)
116+
# carries a JSON-RPC `ErrorData(code, message, data)` and means
117+
# "respond with a protocol error" - re-raise so the kernel surfaces
118+
# it as a top-level JSON-RPC error rather than wrapping it as a
119+
# `CallToolResult(isError=True)` execution failure.
117120
raise
118121
except Exception as e:
119122
raise ToolError(f"Error executing tool {self.name}: {e}") from e
Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
from mcp.server.mcpserver import Context
1+
import pytest
2+
3+
from mcp import Client, types
4+
from mcp.server.mcpserver import Context, MCPServer
25
from mcp.server.mcpserver.tools.base import Tool
6+
from mcp.shared.exceptions import MCPError
37

48

59
def test_context_detected_in_union_annotation():
@@ -8,3 +12,45 @@ def my_tool(x: int, ctx: Context | None) -> str:
812

913
tool = Tool.from_function(my_tool)
1014
assert tool.context_kwarg == "ctx"
15+
16+
17+
@pytest.mark.anyio
18+
async def test_mcperror_raised_from_a_tool_surfaces_as_a_top_level_jsonrpc_error_with_code_and_data_intact():
19+
"""SDK-defined: ``MCPError`` carries JSON-RPC ``ErrorData(code, message, data)``
20+
and means "respond with a protocol error". The tool wrapper re-raises it so
21+
the kernel writes a top-level JSON-RPC error - ``code`` and ``data`` survive
22+
the round-trip rather than being flattened into ``CallToolResult(isError=True)``."""
23+
mcp = MCPServer(name="srv")
24+
25+
@mcp.tool()
26+
async def needs_sampling() -> str:
27+
raise MCPError(
28+
types.MISSING_REQUIRED_CLIENT_CAPABILITY,
29+
"sampling capability required",
30+
data={"requiredCapabilities": ["sampling"]},
31+
)
32+
33+
async with Client(mcp) as client:
34+
with pytest.raises(MCPError) as exc_info:
35+
await client.call_tool("needs_sampling", {})
36+
37+
assert exc_info.value.error.code == types.MISSING_REQUIRED_CLIENT_CAPABILITY
38+
assert exc_info.value.error.data == {"requiredCapabilities": ["sampling"]}
39+
40+
41+
@pytest.mark.anyio
42+
async def test_non_mcperror_exception_raised_from_a_tool_is_wrapped_as_an_is_error_result():
43+
"""SDK-defined: ordinary exceptions from a tool body are execution failures
44+
the LLM should see, so they become ``CallToolResult(isError=True)`` rather
45+
than a protocol-level JSON-RPC error. Pins the other arm of the same branch."""
46+
mcp = MCPServer(name="srv")
47+
48+
@mcp.tool()
49+
async def boom() -> str:
50+
raise RuntimeError("execution failure")
51+
52+
async with Client(mcp) as client:
53+
result = await client.call_tool("boom", {})
54+
55+
assert isinstance(result, types.CallToolResult)
56+
assert result.is_error is True

0 commit comments

Comments
 (0)