Skip to content

Commit 3d41b8a

Browse files
XInke YIXInke YI
authored andcommitted
Add propagate_through_tool_handlers attribute to McpError for protocol flow control
- Add propagate_through_tool_handlers flag to McpError base class - Set UrlElicitationRequiredError to propagate through tool handlers - Update fastmcp and lowlevel servers to check flag instead of specific exception type - Add comprehensive tests for propagation behavior
1 parent 8ac0cab commit 3d41b8a

File tree

4 files changed

+194
-8
lines changed

4 files changed

+194
-8
lines changed

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from mcp.server.fastmcp.exceptions import ToolError
1212
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
1313
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
14-
from mcp.shared.exceptions import UrlElicitationRequiredError
14+
from mcp.shared.exceptions import McpError
1515
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
1616
from mcp.types import Icon, ToolAnnotations
1717

@@ -109,10 +109,13 @@ async def run(
109109
result = self.fn_metadata.convert_result(result)
110110

111111
return result
112-
except UrlElicitationRequiredError:
113-
# Re-raise UrlElicitationRequiredError so it can be properly handled
114-
# as an MCP error response with code -32042
115-
raise
112+
except McpError as e:
113+
# Re-raise protocol flow-control exceptions so they can be properly handled
114+
# as MCP error responses (e.g., code -32042 for URL elicitation)
115+
if e.propagate_through_tool_handlers:
116+
raise
117+
# Other MCP errors should be wrapped as ToolError
118+
raise ToolError(f"Error executing tool {self.name}: {e}") from e
116119
except Exception as e:
117120
raise ToolError(f"Error executing tool {self.name}: {e}") from e
118121

src/mcp/server/lowlevel/server.py

Lines changed: 5 additions & 3 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
@@ -569,10 +569,12 @@ async def handler(req: types.CallToolRequest):
569569
isError=False,
570570
)
571571
)
572-
except UrlElicitationRequiredError:
572+
except McpError as e:
573573
# Re-raise UrlElicitationRequiredError so it can be properly handled
574574
# by _handle_request, which converts it to an error response with code -32042
575-
raise
575+
if e.propagate_through_tool_handlers:
576+
raise
577+
return self._make_error_result(e.error.message)
576578
except Exception as e:
577579
return self._make_error_result(str(e))
578580

src/mcp/shared/exceptions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ class McpError(Exception):
1111
"""
1212

1313
error: ErrorData
14+
propagate_through_tool_handlers: bool = False
15+
"""
16+
If True, this exception should propagate through tool handler exception handling
17+
without being wrapped as a tool error. This is used for protocol-level flow-control
18+
exceptions that need to be converted to JSON-RPC error responses.
19+
"""
1420

1521
def __init__(self, error: ErrorData):
1622
"""Initialize McpError."""
@@ -36,6 +42,12 @@ class UrlElicitationRequiredError(McpError):
3642
])
3743
"""
3844

45+
propagate_through_tool_handlers: bool = True
46+
"""
47+
This exception propagates through tool handlers to be handled as a protocol-level
48+
flow-control mechanism, converted to a JSON-RPC error response with code -32042.
49+
"""
50+
3951
def __init__(
4052
self,
4153
elicitations: list[ElicitRequestURLParams],
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Test that propagate_through_tool_handlers attribute correctly bypasses error wrapping."""
2+
3+
import pytest
4+
5+
from mcp import types
6+
from mcp.server.fastmcp.exceptions import ToolError
7+
from mcp.server.fastmcp.tools.base import Tool
8+
from mcp.server.fastmcp.tools.tool_manager import ToolManager
9+
from mcp.shared.exceptions import McpError, UrlElicitationRequiredError
10+
11+
12+
class TestPropagateThroughToolHandlers:
13+
"""Test the propagate_through_tool_handlers attribute behavior."""
14+
15+
@pytest.mark.anyio
16+
async def test_url_elicitation_required_error_propagates(self):
17+
"""Test that UrlElicitationRequiredError propagates through Tool.run() without wrapping."""
18+
19+
# Create a tool that raises UrlElicitationRequiredError
20+
async def auth_required_tool() -> str:
21+
raise UrlElicitationRequiredError(
22+
[
23+
types.ElicitRequestURLParams(
24+
mode="url",
25+
message="Authorization required",
26+
url="https://example.com/auth",
27+
elicitationId="auth-001",
28+
)
29+
]
30+
)
31+
32+
tool = Tool.from_function(auth_required_tool)
33+
34+
# The exception should propagate, not be wrapped as ToolError
35+
with pytest.raises(UrlElicitationRequiredError) as exc_info:
36+
await tool.run({})
37+
38+
# Verify it's the actual exception, not wrapped
39+
assert isinstance(exc_info.value, UrlElicitationRequiredError)
40+
assert exc_info.value.propagate_through_tool_handlers is True
41+
assert exc_info.value.error.code == types.URL_ELICITATION_REQUIRED
42+
43+
@pytest.mark.anyio
44+
async def test_custom_mcp_error_without_attribute_is_wrapped(self):
45+
"""Test that a custom McpError without propagate_through_tool_handlers is wrapped."""
46+
47+
# Create a custom McpError that doesn't propagate
48+
class CustomMcpError(McpError):
49+
propagate_through_tool_handlers = False # Default, but explicit for clarity
50+
51+
def __init__(self):
52+
error = types.ErrorData(code=-32000, message="Custom error")
53+
super().__init__(error)
54+
55+
async def tool_that_raises_custom_error() -> str:
56+
raise CustomMcpError()
57+
58+
tool = Tool.from_function(tool_that_raises_custom_error)
59+
60+
# The exception should be wrapped as ToolError
61+
with pytest.raises(ToolError) as exc_info:
62+
await tool.run({})
63+
64+
# Verify it's wrapped
65+
assert "Custom error" in str(exc_info.value)
66+
assert isinstance(exc_info.value.__cause__, CustomMcpError)
67+
68+
@pytest.mark.anyio
69+
async def test_custom_mcp_error_with_attribute_propagates(self):
70+
"""Test that a custom McpError with propagate_through_tool_handlers=True propagates."""
71+
72+
# Create a custom McpError that does propagate
73+
class PropagatingMcpError(McpError):
74+
propagate_through_tool_handlers = True
75+
76+
def __init__(self):
77+
error = types.ErrorData(code=-32001, message="Propagating error")
78+
super().__init__(error)
79+
80+
async def tool_that_raises_propagating_error() -> str:
81+
raise PropagatingMcpError()
82+
83+
tool = Tool.from_function(tool_that_raises_propagating_error)
84+
85+
# The exception should propagate, not be wrapped
86+
with pytest.raises(PropagatingMcpError) as exc_info:
87+
await tool.run({})
88+
89+
# Verify it's not wrapped
90+
assert isinstance(exc_info.value, PropagatingMcpError)
91+
assert exc_info.value.propagate_through_tool_handlers is True
92+
93+
@pytest.mark.anyio
94+
async def test_normal_exception_still_wrapped(self):
95+
"""Test that normal exceptions (non-McpError) are still wrapped as ToolError."""
96+
97+
async def tool_that_raises_value_error() -> str:
98+
raise ValueError("Something went wrong")
99+
100+
tool = Tool.from_function(tool_that_raises_value_error)
101+
102+
# Normal exceptions should be wrapped as ToolError
103+
with pytest.raises(ToolError) as exc_info:
104+
await tool.run({})
105+
106+
assert "Something went wrong" in str(exc_info.value)
107+
assert isinstance(exc_info.value.__cause__, ValueError)
108+
109+
@pytest.mark.anyio
110+
async def test_propagates_through_tool_manager(self):
111+
"""Test that propagation works through ToolManager.call_tool()."""
112+
113+
async def auth_tool() -> str:
114+
raise UrlElicitationRequiredError(
115+
[
116+
types.ElicitRequestURLParams(
117+
mode="url",
118+
message="Auth required",
119+
url="https://example.com/auth",
120+
elicitationId="test-auth",
121+
)
122+
]
123+
)
124+
125+
manager = ToolManager()
126+
manager.add_tool(auth_tool)
127+
128+
# Exception should propagate through ToolManager as well
129+
with pytest.raises(UrlElicitationRequiredError) as exc_info:
130+
await manager.call_tool("auth_tool", {})
131+
132+
assert exc_info.value.error.code == types.URL_ELICITATION_REQUIRED
133+
134+
135+
@pytest.mark.anyio
136+
async def test_integration_url_elicitation_propagates_to_jsonrpc():
137+
"""Integration test: Verify UrlElicitationRequiredError becomes JSON-RPC error response."""
138+
from mcp.server.fastmcp import Context, FastMCP
139+
from mcp.server.session import ServerSession
140+
from mcp.shared.memory import create_connected_server_and_client_session
141+
142+
mcp = FastMCP(name="TestServer")
143+
144+
@mcp.tool(description="Tool that requires authentication")
145+
async def secure_tool(ctx: Context[ServerSession, None]) -> str:
146+
raise UrlElicitationRequiredError(
147+
[
148+
types.ElicitRequestURLParams(
149+
mode="url",
150+
message="Authentication required",
151+
url="https://example.com/oauth",
152+
elicitationId="oauth-001",
153+
)
154+
]
155+
)
156+
157+
async with create_connected_server_and_client_session(mcp._mcp_server) as client_session:
158+
await client_session.initialize()
159+
160+
# Should raise McpError with URL_ELICITATION_REQUIRED code
161+
with pytest.raises(McpError) as exc_info:
162+
await client_session.call_tool("secure_tool", {})
163+
164+
# Verify it's a JSON-RPC error response, not a wrapped tool error
165+
error = exc_info.value.error
166+
assert error.code == types.URL_ELICITATION_REQUIRED
167+
assert error.message == "URL elicitation required"
168+
assert error.data is not None
169+
assert "elicitations" in error.data

0 commit comments

Comments
 (0)