Skip to content

Commit 3f0ac89

Browse files
Add UrlElicitationRequiredError and ctx.elicit_url() for URL mode elicitation
This adds the missing pieces for feature parity with the TypeScript SDK: - UrlElicitationRequiredError exception class that can be raised from tool handlers to signal that URL elicitation(s) are required before proceeding. The error carries a list of ElicitRequestURLParams and serializes to JSON-RPC error code -32042. - ctx.elicit_url() method on FastMCP Context for ergonomic URL mode elicitation, matching the existing ctx.elicit() for form mode. - Updated elicitation.py example showing both URL mode patterns: - Using ctx.elicit_url() for direct elicitation - Raising UrlElicitationRequiredError for the "throw error" pattern - Comprehensive tests for the new exception class.
1 parent ffc374d commit 3f0ac89

File tree

5 files changed

+321
-3
lines changed

5 files changed

+321
-3
lines changed

examples/snippets/servers/elicitation.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
1+
"""Elicitation examples demonstrating form and URL mode elicitation.
2+
3+
Form mode elicitation collects structured, non-sensitive data through a schema.
4+
URL mode elicitation directs users to external URLs for sensitive operations
5+
like OAuth flows, credential collection, or payment processing.
6+
"""
7+
8+
import uuid
9+
110
from pydantic import BaseModel, Field
211

312
from mcp.server.fastmcp import Context, FastMCP
413
from mcp.server.session import ServerSession
14+
from mcp.shared.exceptions import UrlElicitationRequiredError
15+
from mcp.types import ElicitRequestURLParams
516

617
mcp = FastMCP(name="Elicitation Example")
718

@@ -18,7 +29,10 @@ class BookingPreferences(BaseModel):
1829

1930
@mcp.tool()
2031
async def book_table(date: str, time: str, party_size: int, ctx: Context[ServerSession, None]) -> str:
21-
"""Book a table with date availability check."""
32+
"""Book a table with date availability check.
33+
34+
This demonstrates form mode elicitation for collecting non-sensitive user input.
35+
"""
2236
# Check if date is available
2337
if date == "2024-12-25":
2438
# Date unavailable - ask user for alternative
@@ -35,3 +49,51 @@ async def book_table(date: str, time: str, party_size: int, ctx: Context[ServerS
3549

3650
# Date available
3751
return f"[SUCCESS] Booked for {date} at {time}"
52+
53+
54+
@mcp.tool()
55+
async def secure_payment(amount: float, ctx: Context[ServerSession, None]) -> str:
56+
"""Process a secure payment requiring URL confirmation.
57+
58+
This demonstrates URL mode elicitation using ctx.elicit_url() for
59+
operations that require out-of-band user interaction.
60+
"""
61+
elicitation_id = str(uuid.uuid4())
62+
63+
result = await ctx.elicit_url(
64+
message=f"Please confirm payment of ${amount:.2f}",
65+
url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}",
66+
elicitation_id=elicitation_id,
67+
)
68+
69+
if result.action == "accept":
70+
# In a real app, the payment confirmation would happen out-of-band
71+
# and you'd verify the payment status from your backend
72+
return f"Payment of ${amount:.2f} initiated - check your browser to complete"
73+
elif result.action == "decline":
74+
return "Payment declined by user"
75+
return "Payment cancelled"
76+
77+
78+
@mcp.tool()
79+
async def connect_service(service_name: str, ctx: Context[ServerSession, None]) -> str:
80+
"""Connect to a third-party service requiring OAuth authorization.
81+
82+
This demonstrates the "throw error" pattern using UrlElicitationRequiredError.
83+
Use this pattern when the tool cannot proceed without user authorization.
84+
"""
85+
elicitation_id = str(uuid.uuid4())
86+
87+
# Raise UrlElicitationRequiredError to signal that the client must complete
88+
# a URL elicitation before this request can be processed.
89+
# The MCP framework will convert this to a -32042 error response.
90+
raise UrlElicitationRequiredError(
91+
[
92+
ElicitRequestURLParams(
93+
mode="url",
94+
message=f"Authorization required to connect to {service_name}",
95+
url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}",
96+
elicitationId=elicitation_id,
97+
)
98+
]
99+
)

src/mcp/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from .client.stdio import StdioServerParameters, stdio_client
44
from .server.session import ServerSession
55
from .server.stdio import stdio_server
6-
from .shared.exceptions import McpError
6+
from .shared.exceptions import McpError, UrlElicitationRequiredError
77
from .types import (
88
CallToolRequest,
99
ClientCapabilities,
@@ -125,6 +125,7 @@
125125
"ToolsCapability",
126126
"ToolUseContent",
127127
"UnsubscribeRequest",
128+
"UrlElicitationRequiredError",
128129
"stdio_client",
129130
"stdio_server",
130131
]

src/mcp/server/fastmcp/server.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,12 @@
4242
from mcp.server.elicitation import (
4343
ElicitationResult,
4444
ElicitSchemaModelT,
45+
UrlElicitationResult,
4546
elicit_with_validation,
4647
)
48+
from mcp.server.elicitation import (
49+
elicit_url as _elicit_url,
50+
)
4751
from mcp.server.fastmcp.exceptions import ResourceError
4852
from mcp.server.fastmcp.prompts import Prompt, PromptManager
4953
from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager
@@ -1200,6 +1204,41 @@ async def elicit(
12001204
related_request_id=self.request_id,
12011205
)
12021206

1207+
async def elicit_url(
1208+
self,
1209+
message: str,
1210+
url: str,
1211+
elicitation_id: str,
1212+
) -> UrlElicitationResult:
1213+
"""Request URL mode elicitation from the client.
1214+
1215+
This directs the user to an external URL for out-of-band interactions
1216+
that must not pass through the MCP client. Use this for:
1217+
- Collecting sensitive credentials (API keys, passwords)
1218+
- OAuth authorization flows with third-party services
1219+
- Payment and subscription flows
1220+
- Any interaction where data should not pass through the LLM context
1221+
1222+
The response indicates whether the user consented to navigate to the URL.
1223+
The actual interaction happens out-of-band. When the elicitation completes,
1224+
call `self.session.send_elicit_complete(elicitation_id)` to notify the client.
1225+
1226+
Args:
1227+
message: Human-readable explanation of why the interaction is needed
1228+
url: The URL the user should navigate to
1229+
elicitation_id: Unique identifier for tracking this elicitation
1230+
1231+
Returns:
1232+
UrlElicitationResult indicating accept, decline, or cancel
1233+
"""
1234+
return await _elicit_url(
1235+
session=self.request_context.session,
1236+
message=message,
1237+
url=url,
1238+
elicitation_id=elicitation_id,
1239+
related_request_id=self.request_id,
1240+
)
1241+
12031242
async def log(
12041243
self,
12051244
level: Literal["debug", "info", "warning", "error"],

src/mcp/shared/exceptions.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
from mcp.types import ErrorData
1+
from __future__ import annotations
2+
3+
from typing import Any, cast
4+
5+
from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData
26

37

48
class McpError(Exception):
@@ -12,3 +16,56 @@ def __init__(self, error: ErrorData):
1216
"""Initialize McpError."""
1317
super().__init__(error.message)
1418
self.error = error
19+
20+
21+
class UrlElicitationRequiredError(McpError):
22+
"""
23+
Specialized error for when a tool requires URL mode elicitation(s) before proceeding.
24+
25+
Servers can raise this error from tool handlers to indicate that the client
26+
must complete one or more URL elicitations before the request can be processed.
27+
28+
Example:
29+
raise UrlElicitationRequiredError([
30+
ElicitRequestURLParams(
31+
mode="url",
32+
message="Authorization required for your files",
33+
url="https://example.com/oauth/authorize",
34+
elicitationId="auth-001"
35+
)
36+
])
37+
"""
38+
39+
def __init__(
40+
self,
41+
elicitations: list[ElicitRequestURLParams],
42+
message: str | None = None,
43+
):
44+
"""Initialize UrlElicitationRequiredError."""
45+
if message is None:
46+
message = f"URL elicitation{'s' if len(elicitations) > 1 else ''} required"
47+
48+
self._elicitations = elicitations
49+
50+
error = ErrorData(
51+
code=URL_ELICITATION_REQUIRED,
52+
message=message,
53+
data={"elicitations": [e.model_dump(by_alias=True, exclude_none=True) for e in elicitations]},
54+
)
55+
super().__init__(error)
56+
57+
@property
58+
def elicitations(self) -> list[ElicitRequestURLParams]:
59+
"""The list of URL elicitations required before the request can proceed."""
60+
return self._elicitations
61+
62+
@classmethod
63+
def from_error(cls, error: ErrorData) -> UrlElicitationRequiredError:
64+
"""Reconstruct from an ErrorData received over the wire."""
65+
if error.code != URL_ELICITATION_REQUIRED:
66+
raise ValueError(f"Expected error code {URL_ELICITATION_REQUIRED}, got {error.code}")
67+
68+
data = cast(dict[str, Any], error.data or {})
69+
raw_elicitations = cast(list[dict[str, Any]], data.get("elicitations", []))
70+
elicitations = [ElicitRequestURLParams.model_validate(e) for e in raw_elicitations]
71+
return cls(elicitations, error.message)

tests/shared/test_exceptions.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Tests for MCP exception classes."""
2+
3+
import pytest
4+
5+
from mcp.shared.exceptions import McpError, UrlElicitationRequiredError
6+
from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData
7+
8+
9+
class TestUrlElicitationRequiredError:
10+
"""Tests for UrlElicitationRequiredError exception class."""
11+
12+
def test_create_with_single_elicitation(self) -> None:
13+
"""Test creating error with a single elicitation."""
14+
elicitation = ElicitRequestURLParams(
15+
mode="url",
16+
message="Auth required",
17+
url="https://example.com/auth",
18+
elicitationId="test-123",
19+
)
20+
error = UrlElicitationRequiredError([elicitation])
21+
22+
assert error.error.code == URL_ELICITATION_REQUIRED
23+
assert error.error.message == "URL elicitation required"
24+
assert len(error.elicitations) == 1
25+
assert error.elicitations[0].elicitationId == "test-123"
26+
27+
def test_create_with_multiple_elicitations(self) -> None:
28+
"""Test creating error with multiple elicitations uses plural message."""
29+
elicitations = [
30+
ElicitRequestURLParams(
31+
mode="url",
32+
message="Auth 1",
33+
url="https://example.com/auth1",
34+
elicitationId="test-1",
35+
),
36+
ElicitRequestURLParams(
37+
mode="url",
38+
message="Auth 2",
39+
url="https://example.com/auth2",
40+
elicitationId="test-2",
41+
),
42+
]
43+
error = UrlElicitationRequiredError(elicitations)
44+
45+
assert error.error.message == "URL elicitations required" # Plural
46+
assert len(error.elicitations) == 2
47+
48+
def test_custom_message(self) -> None:
49+
"""Test creating error with a custom message."""
50+
elicitation = ElicitRequestURLParams(
51+
mode="url",
52+
message="Auth required",
53+
url="https://example.com/auth",
54+
elicitationId="test-123",
55+
)
56+
error = UrlElicitationRequiredError([elicitation], message="Custom message")
57+
58+
assert error.error.message == "Custom message"
59+
60+
def test_from_error_data(self) -> None:
61+
"""Test reconstructing error from ErrorData."""
62+
error_data = ErrorData(
63+
code=URL_ELICITATION_REQUIRED,
64+
message="URL elicitation required",
65+
data={
66+
"elicitations": [
67+
{
68+
"mode": "url",
69+
"message": "Auth required",
70+
"url": "https://example.com/auth",
71+
"elicitationId": "test-123",
72+
}
73+
]
74+
},
75+
)
76+
77+
error = UrlElicitationRequiredError.from_error(error_data)
78+
79+
assert len(error.elicitations) == 1
80+
assert error.elicitations[0].elicitationId == "test-123"
81+
assert error.elicitations[0].url == "https://example.com/auth"
82+
83+
def test_from_error_data_wrong_code(self) -> None:
84+
"""Test that from_error raises ValueError for wrong error code."""
85+
error_data = ErrorData(
86+
code=-32600, # Wrong code
87+
message="Some other error",
88+
data={},
89+
)
90+
91+
with pytest.raises(ValueError, match="Expected error code"):
92+
UrlElicitationRequiredError.from_error(error_data)
93+
94+
def test_serialization_roundtrip(self) -> None:
95+
"""Test that error can be serialized and reconstructed."""
96+
original = UrlElicitationRequiredError(
97+
[
98+
ElicitRequestURLParams(
99+
mode="url",
100+
message="Auth required",
101+
url="https://example.com/auth",
102+
elicitationId="test-123",
103+
)
104+
]
105+
)
106+
107+
# Simulate serialization over wire
108+
error_data = original.error
109+
110+
# Reconstruct
111+
reconstructed = UrlElicitationRequiredError.from_error(error_data)
112+
113+
assert reconstructed.elicitations[0].elicitationId == original.elicitations[0].elicitationId
114+
assert reconstructed.elicitations[0].url == original.elicitations[0].url
115+
assert reconstructed.elicitations[0].message == original.elicitations[0].message
116+
117+
def test_error_data_contains_elicitations(self) -> None:
118+
"""Test that error data contains properly serialized elicitations."""
119+
elicitation = ElicitRequestURLParams(
120+
mode="url",
121+
message="Please authenticate",
122+
url="https://example.com/oauth",
123+
elicitationId="oauth-flow-1",
124+
)
125+
error = UrlElicitationRequiredError([elicitation])
126+
127+
assert error.error.data is not None
128+
assert "elicitations" in error.error.data
129+
elicit_data = error.error.data["elicitations"][0]
130+
assert elicit_data["mode"] == "url"
131+
assert elicit_data["message"] == "Please authenticate"
132+
assert elicit_data["url"] == "https://example.com/oauth"
133+
assert elicit_data["elicitationId"] == "oauth-flow-1"
134+
135+
def test_inherits_from_mcp_error(self) -> None:
136+
"""Test that UrlElicitationRequiredError inherits from McpError."""
137+
elicitation = ElicitRequestURLParams(
138+
mode="url",
139+
message="Auth required",
140+
url="https://example.com/auth",
141+
elicitationId="test-123",
142+
)
143+
error = UrlElicitationRequiredError([elicitation])
144+
145+
assert isinstance(error, McpError)
146+
assert isinstance(error, Exception)
147+
148+
def test_exception_message(self) -> None:
149+
"""Test that exception message is set correctly."""
150+
elicitation = ElicitRequestURLParams(
151+
mode="url",
152+
message="Auth required",
153+
url="https://example.com/auth",
154+
elicitationId="test-123",
155+
)
156+
error = UrlElicitationRequiredError([elicitation])
157+
158+
# The exception's string representation should match the message
159+
assert str(error) == "URL elicitation required"

0 commit comments

Comments
 (0)