Skip to content

Commit 8ac0cab

Browse files
authored
Fix for Url Elicitation issue 1768 (#1780)
1 parent 65b36de commit 8ac0cab

File tree

3 files changed

+123
-1
lines changed

3 files changed

+123
-1
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +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
1415
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
1516
from mcp.types import Icon, ToolAnnotations
1617

@@ -108,6 +109,10 @@ async def run(
108109
result = self.fn_metadata.convert_result(result)
109110

110111
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
111116
except Exception as e:
112117
raise ToolError(f"Error executing tool {self.name}: {e}") from e
113118

src/mcp/server/lowlevel/server.py

Lines changed: 5 additions & 1 deletion
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
93+
from mcp.shared.exceptions import McpError, UrlElicitationRequiredError
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,6 +569,10 @@ async def handler(req: types.CallToolRequest):
569569
isError=False,
570570
)
571571
)
572+
except UrlElicitationRequiredError:
573+
# Re-raise UrlElicitationRequiredError so it can be properly handled
574+
# by _handle_request, which converts it to an error response with code -32042
575+
raise
572576
except Exception as e:
573577
return self._make_error_result(str(e))
574578

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Test that UrlElicitationRequiredError is properly propagated as MCP error."""
2+
3+
import pytest
4+
5+
from mcp import types
6+
from mcp.server.fastmcp import Context, FastMCP
7+
from mcp.server.session import ServerSession
8+
from mcp.shared.exceptions import McpError, UrlElicitationRequiredError
9+
from mcp.shared.memory import create_connected_server_and_client_session
10+
11+
12+
@pytest.mark.anyio
13+
async def test_url_elicitation_error_thrown_from_tool():
14+
"""Test that UrlElicitationRequiredError raised from a tool is received as McpError by client."""
15+
mcp = FastMCP(name="UrlElicitationErrorServer")
16+
17+
@mcp.tool(description="A tool that raises UrlElicitationRequiredError")
18+
async def connect_service(service_name: str, ctx: Context[ServerSession, None]) -> str:
19+
# This tool cannot proceed without authorization
20+
raise UrlElicitationRequiredError(
21+
[
22+
types.ElicitRequestURLParams(
23+
mode="url",
24+
message=f"Authorization required to connect to {service_name}",
25+
url=f"https://{service_name}.example.com/oauth/authorize",
26+
elicitationId=f"{service_name}-auth-001",
27+
)
28+
]
29+
)
30+
31+
async with create_connected_server_and_client_session(mcp._mcp_server) as client_session:
32+
await client_session.initialize()
33+
34+
# Call the tool - it should raise McpError with URL_ELICITATION_REQUIRED code
35+
with pytest.raises(McpError) as exc_info:
36+
await client_session.call_tool("connect_service", {"service_name": "github"})
37+
38+
# Verify the error details
39+
error = exc_info.value.error
40+
assert error.code == types.URL_ELICITATION_REQUIRED
41+
assert error.message == "URL elicitation required"
42+
43+
# Verify the error data contains elicitations
44+
assert error.data is not None
45+
assert "elicitations" in error.data
46+
elicitations = error.data["elicitations"]
47+
assert len(elicitations) == 1
48+
assert elicitations[0]["mode"] == "url"
49+
assert elicitations[0]["url"] == "https://github.example.com/oauth/authorize"
50+
assert elicitations[0]["elicitationId"] == "github-auth-001"
51+
52+
53+
@pytest.mark.anyio
54+
async def test_url_elicitation_error_from_error():
55+
"""Test that client can reconstruct UrlElicitationRequiredError from McpError."""
56+
mcp = FastMCP(name="UrlElicitationErrorServer")
57+
58+
@mcp.tool(description="A tool that raises UrlElicitationRequiredError with multiple elicitations")
59+
async def multi_auth(ctx: Context[ServerSession, None]) -> str:
60+
raise UrlElicitationRequiredError(
61+
[
62+
types.ElicitRequestURLParams(
63+
mode="url",
64+
message="GitHub authorization required",
65+
url="https://github.example.com/oauth",
66+
elicitationId="github-auth",
67+
),
68+
types.ElicitRequestURLParams(
69+
mode="url",
70+
message="Google Drive authorization required",
71+
url="https://drive.google.com/oauth",
72+
elicitationId="gdrive-auth",
73+
),
74+
]
75+
)
76+
77+
async with create_connected_server_and_client_session(mcp._mcp_server) as client_session:
78+
await client_session.initialize()
79+
80+
# Call the tool and catch the error
81+
with pytest.raises(McpError) as exc_info:
82+
await client_session.call_tool("multi_auth", {})
83+
84+
# Reconstruct the typed error
85+
mcp_error = exc_info.value
86+
assert mcp_error.error.code == types.URL_ELICITATION_REQUIRED
87+
88+
url_error = UrlElicitationRequiredError.from_error(mcp_error.error)
89+
90+
# Verify the reconstructed error has both elicitations
91+
assert len(url_error.elicitations) == 2
92+
assert url_error.elicitations[0].elicitationId == "github-auth"
93+
assert url_error.elicitations[1].elicitationId == "gdrive-auth"
94+
95+
96+
@pytest.mark.anyio
97+
async def test_normal_exceptions_still_return_error_result():
98+
"""Test that normal exceptions still return CallToolResult with isError=True."""
99+
mcp = FastMCP(name="NormalErrorServer")
100+
101+
@mcp.tool(description="A tool that raises a normal exception")
102+
async def failing_tool(ctx: Context[ServerSession, None]) -> str:
103+
raise ValueError("Something went wrong")
104+
105+
async with create_connected_server_and_client_session(mcp._mcp_server) as client_session:
106+
await client_session.initialize()
107+
108+
# Normal exceptions should be returned as error results, not McpError
109+
result = await client_session.call_tool("failing_tool", {})
110+
assert result.isError is True
111+
assert len(result.content) == 1
112+
assert isinstance(result.content[0], types.TextContent)
113+
assert "Something went wrong" in result.content[0].text

0 commit comments

Comments
 (0)