Skip to content

Commit 87b8d21

Browse files
committed
Implement SEP-1036: URL mode elicitation for secure out-of-band interactions
This commit adds support for URL mode elicitation as specified in SEP-1036, enabling servers to direct users to external URLs for sensitive interactions that must not pass through the MCP client. Key changes: Types (src/mcp/types.py): - Add ELICITATION_REQUIRED error code (-32000) - Update ElicitationCapability to support form and url modes - Add ElicitTrackRequest and ElicitTrackResult for progress tracking - Add UrlElicitationInfo and ElicitationRequiredErrorData types - Update ElicitRequestParams with mode field and URL mode parameters Server (src/mcp/server/): - Add elicit_url() helper function in elicitation.py - Add elicit_form() and elicit_url() methods to ServerSession - Maintain backward compatibility with existing elicit() method Client (src/mcp/client/session.py): - Update capability negotiation for form and URL modes - Add track_elicitation() method for progress monitoring Tests: - Comprehensive test coverage for URL mode elicitation - Verify backward compatibility with form mode - All 311 existing tests pass Use cases enabled: - OAuth authorization flows with third-party services - Secure credential collection (API keys, passwords) - Payment and subscription flows - Any sensitive interaction requiring out-of-band handling Breaking changes: - ElicitRequestParams now requires mode field ("form" or "url") - Clients must declare which elicitation modes they support Closes: modelcontextprotocol/modelcontextprotocol#887
1 parent de2289d commit 87b8d21

File tree

5 files changed

+500
-12
lines changed

5 files changed

+500
-12
lines changed

src/mcp/client/session.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,12 @@ def __init__(
138138
async def initialize(self) -> types.InitializeResult:
139139
sampling = types.SamplingCapability() if self._sampling_callback is not _default_sampling_callback else None
140140
elicitation = (
141-
types.ElicitationCapability() if self._elicitation_callback is not _default_elicitation_callback else None
141+
types.ElicitationCapability(
142+
form=types.FormElicitationCapability(),
143+
url=types.UrlElicitationCapability(),
144+
)
145+
if self._elicitation_callback is not _default_elicitation_callback
146+
else None
142147
)
143148
roots = (
144149
# TODO: Should this be based on whether we
@@ -491,6 +496,29 @@ async def list_tools(
491496

492497
return result
493498

499+
async def track_elicitation(
500+
self,
501+
elicitation_id: str,
502+
progress_token: types.ProgressToken | None = None,
503+
) -> types.ElicitTrackResult:
504+
"""Send an elicitation/track request to monitor URL mode elicitation progress.
505+
506+
Args:
507+
elicitation_id: The unique identifier of the elicitation to track
508+
progress_token: Optional token for receiving progress notifications
509+
510+
Returns:
511+
ElicitTrackResult indicating the status of the elicitation
512+
"""
513+
params = types.ElicitTrackRequestParams(elicitationId=elicitation_id)
514+
if progress_token is not None:
515+
params.meta = types.RequestParams.Meta(progressToken=progress_token)
516+
517+
return await self.send_request(
518+
types.ClientRequest(types.ElicitTrackRequest(params=params)),
519+
types.ElicitTrackResult,
520+
)
521+
494522
async def send_roots_list_changed(self) -> None:
495523
"""Send a roots/list_changed notification."""
496524
await self.send_notification(types.ClientNotification(types.RootsListChangedNotification()))

src/mcp/server/elicitation.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ class CancelledElicitation(BaseModel):
3636
ElicitationResult = AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation
3737

3838

39+
class AcceptedUrlElicitation(BaseModel):
40+
"""Result when user accepts a URL mode elicitation."""
41+
42+
action: Literal["accept"] = "accept"
43+
44+
45+
UrlElicitationResult = AcceptedUrlElicitation | DeclinedElicitation | CancelledElicitation
46+
47+
3948
# Primitive types allowed in elicitation schemas
4049
_ELICITATION_PRIMITIVE_TYPES = (str, int, float, bool)
4150

@@ -79,20 +88,22 @@ async def elicit_with_validation(
7988
schema: type[ElicitSchemaModelT],
8089
related_request_id: RequestId | None = None,
8190
) -> ElicitationResult[ElicitSchemaModelT]:
82-
"""Elicit information from the client/user with schema validation.
91+
"""Elicit information from the client/user with schema validation (form mode).
8392
8493
This method can be used to interactively ask for additional information from the
8594
client within a tool's execution. The client might display the message to the
8695
user and collect a response according to the provided schema. Or in case a
8796
client is an agent, it might decide how to handle the elicitation -- either by asking
8897
the user or automatically generating a response.
98+
99+
For sensitive data like credentials or OAuth flows, use elicit_url() instead.
89100
"""
90101
# Validate that schema only contains primitive types and fail loudly if not
91102
_validate_elicitation_schema(schema)
92103

93104
json_schema = schema.model_json_schema()
94105

95-
result = await session.elicit(
106+
result = await session.elicit_form(
96107
message=message,
97108
requestedSchema=json_schema,
98109
related_request_id=related_request_id,
@@ -109,3 +120,51 @@ async def elicit_with_validation(
109120
else:
110121
# This should never happen, but handle it just in case
111122
raise ValueError(f"Unexpected elicitation action: {result.action}")
123+
124+
125+
async def elicit_url(
126+
session: ServerSession,
127+
message: str,
128+
url: str,
129+
elicitation_id: str,
130+
related_request_id: RequestId | None = None,
131+
) -> UrlElicitationResult:
132+
"""Elicit information from the user via out-of-band URL navigation (URL mode).
133+
134+
This method directs the user to an external URL where sensitive interactions can
135+
occur without passing data through the MCP client. Use this for:
136+
- Collecting sensitive credentials (API keys, passwords)
137+
- OAuth authorization flows with third-party services
138+
- Payment and subscription flows
139+
- Any interaction where data should not pass through the LLM context
140+
141+
The response indicates whether the user consented to navigate to the URL.
142+
The actual interaction happens out-of-band, and you can track progress using
143+
session.track_elicitation().
144+
145+
Args:
146+
session: The server session
147+
message: Human-readable explanation of why the interaction is needed
148+
url: The URL the user should navigate to
149+
elicitation_id: Unique identifier for tracking this elicitation
150+
related_request_id: Optional ID of the request that triggered this elicitation
151+
152+
Returns:
153+
UrlElicitationResult indicating accept, decline, or cancel
154+
"""
155+
result = await session.elicit_url(
156+
message=message,
157+
url=url,
158+
elicitation_id=elicitation_id,
159+
related_request_id=related_request_id,
160+
)
161+
162+
if result.action == "accept":
163+
return AcceptedUrlElicitation()
164+
elif result.action == "decline":
165+
return DeclinedElicitation()
166+
elif result.action == "cancel":
167+
return CancelledElicitation()
168+
else:
169+
# This should never happen, but handle it just in case
170+
raise ValueError(f"Unexpected elicitation action: {result.action}")

src/mcp/server/session.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,19 +260,43 @@ async def elicit(
260260
requestedSchema: types.ElicitRequestedSchema,
261261
related_request_id: types.RequestId | None = None,
262262
) -> types.ElicitResult:
263-
"""Send an elicitation/create request.
263+
"""Send a form mode elicitation/create request.
264264
265265
Args:
266266
message: The message to present to the user
267267
requestedSchema: Schema defining the expected response structure
268+
related_request_id: Optional ID of the request that triggered this elicitation
268269
269270
Returns:
270271
The client's response
272+
273+
Note:
274+
This method is deprecated in favor of elicit_form(). It remains for
275+
backward compatibility but new code should use elicit_form().
276+
"""
277+
return await self.elicit_form(message, requestedSchema, related_request_id)
278+
279+
async def elicit_form(
280+
self,
281+
message: str,
282+
requestedSchema: types.ElicitRequestedSchema,
283+
related_request_id: types.RequestId | None = None,
284+
) -> types.ElicitResult:
285+
"""Send a form mode elicitation/create request.
286+
287+
Args:
288+
message: The message to present to the user
289+
requestedSchema: Schema defining the expected response structure
290+
related_request_id: Optional ID of the request that triggered this elicitation
291+
292+
Returns:
293+
The client's response with form data
271294
"""
272295
return await self.send_request(
273296
types.ServerRequest(
274297
types.ElicitRequest(
275298
params=types.ElicitRequestParams(
299+
mode="form",
276300
message=message,
277301
requestedSchema=requestedSchema,
278302
),
@@ -282,6 +306,42 @@ async def elicit(
282306
metadata=ServerMessageMetadata(related_request_id=related_request_id),
283307
)
284308

309+
async def elicit_url(
310+
self,
311+
message: str,
312+
url: str,
313+
elicitation_id: str,
314+
related_request_id: types.RequestId | None = None,
315+
) -> types.ElicitResult:
316+
"""Send a URL mode elicitation/create request.
317+
318+
This directs the user to an external URL for out-of-band interactions
319+
like OAuth flows, credential collection, or payment processing.
320+
321+
Args:
322+
message: Human-readable explanation of why the interaction is needed
323+
url: The URL the user should navigate to
324+
elicitation_id: Unique identifier for tracking this elicitation
325+
related_request_id: Optional ID of the request that triggered this elicitation
326+
327+
Returns:
328+
The client's response indicating acceptance, decline, or cancellation
329+
"""
330+
return await self.send_request(
331+
types.ServerRequest(
332+
types.ElicitRequest(
333+
params=types.ElicitRequestParams(
334+
mode="url",
335+
message=message,
336+
url=url,
337+
elicitationId=elicitation_id,
338+
),
339+
)
340+
),
341+
types.ElicitResult,
342+
metadata=ServerMessageMetadata(related_request_id=related_request_id),
343+
)
344+
285345
async def send_ping(self) -> types.EmptyResult:
286346
"""Send a ping request."""
287347
return await self.send_request(

0 commit comments

Comments
 (0)