Implement SEP-1036: URL mode elicitation for secure out-of-band interactions (#1580)

Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
This commit is contained in:
Chris Coutinho 2025-11-25 12:00:21 +01:00 • committed by GitHub
commit 02b7889929
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1372 additions and 15 deletions

View file

@ -3,7 +3,7 @@ from .client.session_group import ClientSessionGroup
from .client.stdio import StdioServerParameters, stdio_client
from .server.session import ServerSession
from .server.stdio import stdio_server
from .shared.exceptions import McpError
from .shared.exceptions import McpError, UrlElicitationRequiredError
from .types import (
CallToolRequest,
ClientCapabilities,
@ -125,6 +125,7 @@ __all__ = [
"ToolsCapability",
"ToolUseContent",
"UnsubscribeRequest",
"UrlElicitationRequiredError",
"stdio_client",
"stdio_server",
]

View file

@ -138,7 +138,12 @@ class ClientSession(
async def initialize(self) -> types.InitializeResult:
sampling = types.SamplingCapability() if self._sampling_callback is not _default_sampling_callback else None
elicitation = (
types.ElicitationCapability() if self._elicitation_callback is not _default_elicitation_callback else None
types.ElicitationCapability(
form=types.FormElicitationCapability(),
url=types.UrlElicitationCapability(),
)
if self._elicitation_callback is not _default_elicitation_callback
else None
)
roots = (
# TODO: Should this be based on whether we
@ -552,5 +557,10 @@ class ClientSession(
match notification.root:
case types.LoggingMessageNotification(params=params):
await self._logging_callback(params)
case types.ElicitCompleteNotification(params=params):
# Handle elicitation completion notification
# Clients MAY use this to retry requests or update UI
# The notification contains the elicitationId of the completed elicitation
pass
case _:
pass

View file

@ -36,6 +36,15 @@ class CancelledElicitation(BaseModel):
ElicitationResult = AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation
class AcceptedUrlElicitation(BaseModel):
"""Result when user accepts a URL mode elicitation."""
action: Literal["accept"] = "accept"
UrlElicitationResult = AcceptedUrlElicitation | DeclinedElicitation | CancelledElicitation
# Primitive types allowed in elicitation schemas
_ELICITATION_PRIMITIVE_TYPES = (str, int, float, bool)
@ -99,20 +108,22 @@ async def elicit_with_validation(
schema: type[ElicitSchemaModelT],
related_request_id: RequestId | None = None,
) -> ElicitationResult[ElicitSchemaModelT]:
"""Elicit information from the client/user with schema validation.
"""Elicit information from the client/user with schema validation (form mode).
This method can be used to interactively ask for additional information from the
client within a tool's execution. The client might display the message to the
user and collect a response according to the provided schema. Or in case a
client is an agent, it might decide how to handle the elicitation -- either by asking
the user or automatically generating a response.
For sensitive data like credentials or OAuth flows, use elicit_url() instead.
"""
# Validate that schema only contains primitive types and fail loudly if not
_validate_elicitation_schema(schema)
json_schema = schema.model_json_schema()
result = await session.elicit(
result = await session.elicit_form(
message=message,
requestedSchema=json_schema,
related_request_id=related_request_id,
@ -129,3 +140,51 @@ async def elicit_with_validation(
else: # pragma: no cover
# This should never happen, but handle it just in case
raise ValueError(f"Unexpected elicitation action: {result.action}")
async def elicit_url(
session: ServerSession,
message: str,
url: str,
elicitation_id: str,
related_request_id: RequestId | None = None,
) -> UrlElicitationResult:
"""Elicit information from the user via out-of-band URL navigation (URL mode).
This method directs the user to an external URL where sensitive interactions can
occur without passing data through the MCP client. Use this for:
- Collecting sensitive credentials (API keys, passwords)
- OAuth authorization flows with third-party services
- Payment and subscription flows
- Any interaction where data should not pass through the LLM context
The response indicates whether the user consented to navigate to the URL.
The actual interaction happens out-of-band. When the elicitation completes,
the server should send an ElicitCompleteNotification to notify the client.
Args:
session: The server session
message: Human-readable explanation of why the interaction is needed
url: The URL the user should navigate to
elicitation_id: Unique identifier for tracking this elicitation
related_request_id: Optional ID of the request that triggered this elicitation
Returns:
UrlElicitationResult indicating accept, decline, or cancel
"""
result = await session.elicit_url(
message=message,
url=url,
elicitation_id=elicitation_id,
related_request_id=related_request_id,
)
if result.action == "accept":
return AcceptedUrlElicitation()
elif result.action == "decline":
return DeclinedElicitation()
elif result.action == "cancel":
return CancelledElicitation()
else: # pragma: no cover
# This should never happen, but handle it just in case
raise ValueError(f"Unexpected elicitation action: {result.action}")

View file

@ -42,8 +42,12 @@ from mcp.server.auth.settings import AuthSettings
from mcp.server.elicitation import (
ElicitationResult,
ElicitSchemaModelT,
UrlElicitationResult,
elicit_with_validation,
)
from mcp.server.elicitation import (
elicit_url as _elicit_url,
)
from mcp.server.fastmcp.exceptions import ResourceError
from mcp.server.fastmcp.prompts import Prompt, PromptManager
from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager
@ -1204,6 +1208,41 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]):
related_request_id=self.request_id,
)
async def elicit_url(
self,
message: str,
url: str,
elicitation_id: str,
) -> UrlElicitationResult:
"""Request URL mode elicitation from the client.
This directs the user to an external URL for out-of-band interactions
that must not pass through the MCP client. Use this for:
- Collecting sensitive credentials (API keys, passwords)
- OAuth authorization flows with third-party services
- Payment and subscription flows
- Any interaction where data should not pass through the LLM context
The response indicates whether the user consented to navigate to the URL.
The actual interaction happens out-of-band. When the elicitation completes,
call `self.session.send_elicit_complete(elicitation_id)` to notify the client.
Args:
message: Human-readable explanation of why the interaction is needed
url: The URL the user should navigate to
elicitation_id: Unique identifier for tracking this elicitation
Returns:
UrlElicitationResult indicating accept, decline, or cancel
"""
return await _elicit_url(
session=self.request_context.session,
message=message,
url=url,
elicitation_id=elicitation_id,
related_request_id=self.request_id,
)
async def log(
self,
level: Literal["debug", "info", "warning", "error"],

View file

@ -335,19 +335,42 @@ class ServerSession(
requestedSchema: types.ElicitRequestedSchema,
related_request_id: types.RequestId | None = None,
) -> types.ElicitResult:
"""Send an elicitation/create request.
"""Send a form mode elicitation/create request.
Args:
message: The message to present to the user
requestedSchema: Schema defining the expected response structure
related_request_id: Optional ID of the request that triggered this elicitation
Returns:
The client's response
Note:
This method is deprecated in favor of elicit_form(). It remains for
backward compatibility but new code should use elicit_form().
"""
return await self.elicit_form(message, requestedSchema, related_request_id)
async def elicit_form(
self,
message: str,
requestedSchema: types.ElicitRequestedSchema,
related_request_id: types.RequestId | None = None,
) -> types.ElicitResult:
"""Send a form mode elicitation/create request.
Args:
message: The message to present to the user
requestedSchema: Schema defining the expected response structure
related_request_id: Optional ID of the request that triggered this elicitation
Returns:
The client's response with form data
"""
return await self.send_request(
types.ServerRequest(
types.ElicitRequest(
params=types.ElicitRequestParams(
params=types.ElicitRequestFormParams(
message=message,
requestedSchema=requestedSchema,
),
@ -357,6 +380,41 @@ class ServerSession(
metadata=ServerMessageMetadata(related_request_id=related_request_id),
)
async def elicit_url(
self,
message: str,
url: str,
elicitation_id: str,
related_request_id: types.RequestId | None = None,
) -> types.ElicitResult:
"""Send a URL mode elicitation/create request.
This directs the user to an external URL for out-of-band interactions
like OAuth flows, credential collection, or payment processing.
Args:
message: Human-readable explanation of why the interaction is needed
url: The URL the user should navigate to
elicitation_id: Unique identifier for tracking this elicitation
related_request_id: Optional ID of the request that triggered this elicitation
Returns:
The client's response indicating acceptance, decline, or cancellation
"""
return await self.send_request(
types.ServerRequest(
types.ElicitRequest(
params=types.ElicitRequestURLParams(
message=message,
url=url,
elicitationId=elicitation_id,
),
)
),
types.ElicitResult,
metadata=ServerMessageMetadata(related_request_id=related_request_id),
)
async def send_ping(self) -> types.EmptyResult: # pragma: no cover
"""Send a ping request."""
return await self.send_request(
@ -399,6 +457,30 @@ class ServerSession(
"""Send a prompt list changed notification."""
await self.send_notification(types.ServerNotification(types.PromptListChangedNotification()))
async def send_elicit_complete(
self,
elicitation_id: str,
related_request_id: types.RequestId | None = None,
) -> None:
"""Send an elicitation completion notification.
This should be sent when a URL mode elicitation has been completed
out-of-band to inform the client that it may retry any requests
that were waiting for this elicitation.
Args:
elicitation_id: The unique identifier of the completed elicitation
related_request_id: Optional ID of the request that triggered this
"""
await self.send_notification(
types.ServerNotification(
types.ElicitCompleteNotification(
params=types.ElicitCompleteNotificationParams(elicitationId=elicitation_id)
)
),
related_request_id,
)
async def _handle_incoming(self, req: ServerRequestResponder) -> None:
await self._incoming_message_stream_writer.send(req)

View file

@ -1,4 +1,8 @@
from mcp.types import ErrorData
from __future__ import annotations
from typing import Any, cast
from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData
class McpError(Exception):
@ -12,3 +16,56 @@ class McpError(Exception):
"""Initialize McpError."""
super().__init__(error.message)
self.error = error
class UrlElicitationRequiredError(McpError):
"""
Specialized error for when a tool requires URL mode elicitation(s) before proceeding.
Servers can raise this error from tool handlers to indicate that the client
must complete one or more URL elicitations before the request can be processed.
Example:
raise UrlElicitationRequiredError([
ElicitRequestURLParams(
mode="url",
message="Authorization required for your files",
url="https://example.com/oauth/authorize",
elicitationId="auth-001"
)
])
"""
def __init__(
self,
elicitations: list[ElicitRequestURLParams],
message: str | None = None,
):
"""Initialize UrlElicitationRequiredError."""
if message is None:
message = f"URL elicitation{'s' if len(elicitations) > 1 else ''} required"
self._elicitations = elicitations
error = ErrorData(
code=URL_ELICITATION_REQUIRED,
message=message,
data={"elicitations": [e.model_dump(by_alias=True, exclude_none=True) for e in elicitations]},
)
super().__init__(error)
@property
def elicitations(self) -> list[ElicitRequestURLParams]:
"""The list of URL elicitations required before the request can proceed."""
return self._elicitations
@classmethod
def from_error(cls, error: ErrorData) -> UrlElicitationRequiredError:
"""Reconstruct from an ErrorData received over the wire."""
if error.code != URL_ELICITATION_REQUIRED:
raise ValueError(f"Expected error code {URL_ELICITATION_REQUIRED}, got {error.code}")
data = cast(dict[str, Any], error.data or {})
raw_elicitations = cast(list[dict[str, Any]], data.get("elicitations", []))
elicitations = [ElicitRequestURLParams.model_validate(e) for e in raw_elicitations]
return cls(elicitations, error.message)

View file

@ -146,6 +146,10 @@ class JSONRPCResponse(BaseModel):
model_config = ConfigDict(extra="allow")
# MCP-specific error codes in the range [-32000, -32099]
URL_ELICITATION_REQUIRED = -32042
"""Error code indicating that a URL mode elicitation is required before the request can be processed."""
# SDK error codes
CONNECTION_CLOSED = -32000
# REQUEST_TIMEOUT = -32001 # the typescript sdk uses this
@ -272,8 +276,29 @@ class SamplingToolsCapability(BaseModel):
model_config = ConfigDict(extra="allow")
class FormElicitationCapability(BaseModel):
"""Capability for form mode elicitation."""
model_config = ConfigDict(extra="allow")
class UrlElicitationCapability(BaseModel):
"""Capability for URL mode elicitation."""
model_config = ConfigDict(extra="allow")
class ElicitationCapability(BaseModel):
"""Capability for elicitation operations."""
"""Capability for elicitation operations.
Clients must support at least one mode (form or url).
"""
form: FormElicitationCapability | None = None
"""Present if the client supports form mode elicitation."""
url: UrlElicitationCapability | None = None
"""Present if the client supports URL mode elicitation."""
model_config = ConfigDict(extra="allow")
@ -1411,6 +1436,32 @@ class CancelledNotification(Notification[CancelledNotificationParams, Literal["n
params: CancelledNotificationParams
class ElicitCompleteNotificationParams(NotificationParams):
"""Parameters for elicitation completion notifications."""
elicitationId: str
"""The unique identifier of the elicitation that was completed."""
model_config = ConfigDict(extra="allow")
class ElicitCompleteNotification(
Notification[ElicitCompleteNotificationParams, Literal["notifications/elicitation/complete"]]
):
"""
A notification from the server to the client, informing it that a URL mode
elicitation has been completed.
Clients MAY use the notification to automatically retry requests that received a
URLElicitationRequiredError, update the user interface, or otherwise continue
an interaction. However, because delivery of the notification is not guaranteed,
clients must not wait indefinitely for a notification from the server.
"""
method: Literal["notifications/elicitation/complete"] = "notifications/elicitation/complete"
params: ElicitCompleteNotificationParams
class ClientRequest(
RootModel[
PingRequest
@ -1442,14 +1493,58 @@ ElicitRequestedSchema: TypeAlias = dict[str, Any]
"""Schema for elicitation requests."""
class ElicitRequestParams(RequestParams):
"""Parameters for elicitation requests."""
class ElicitRequestFormParams(RequestParams):
"""Parameters for form mode elicitation requests.
Form mode collects non-sensitive information from the user via an in-band form
rendered by the client.
"""
mode: Literal["form"] = "form"
"""The elicitation mode (always "form" for this type)."""
message: str
"""The message to present to the user describing what information is being requested."""
requestedSchema: ElicitRequestedSchema
"""
A restricted subset of JSON Schema defining the structure of expected response.
Only top-level properties are allowed, without nesting.
"""
model_config = ConfigDict(extra="allow")
class ElicitRequestURLParams(RequestParams):
"""Parameters for URL mode elicitation requests.
URL mode directs users to external URLs for sensitive out-of-band interactions
like OAuth flows, credential collection, or payment processing.
"""
mode: Literal["url"] = "url"
"""The elicitation mode (always "url" for this type)."""
message: str
"""The message to present to the user explaining why the interaction is needed."""
url: str
"""The URL that the user should navigate to."""
elicitationId: str
"""
The ID of the elicitation, which must be unique within the context of the server.
The client MUST treat this ID as an opaque value.
"""
model_config = ConfigDict(extra="allow")
# Union type for elicitation request parameters
ElicitRequestParams: TypeAlias = ElicitRequestURLParams | ElicitRequestFormParams
"""Parameters for elicitation requests - either form or URL mode."""
class ElicitRequest(Request[ElicitRequestParams, Literal["elicitation/create"]]):
"""A request from the server to elicit information from the client."""
@ -1463,18 +1558,33 @@ class ElicitResult(Result):
action: Literal["accept", "decline", "cancel"]
"""
The user action in response to the elicitation.
- "accept": User submitted the form/confirmed the action
- "accept": User submitted the form/confirmed the action (or consented to URL navigation)
- "decline": User explicitly declined the action
- "cancel": User dismissed without making an explicit choice
"""
content: dict[str, str | int | float | bool | list[str] | None] | None = None
"""
The submitted form data, only present when action is "accept".
Contains values matching the requested schema.
The submitted form data, only present when action is "accept" in form mode.
Contains values matching the requested schema. Values can be strings, integers,
booleans, or arrays of strings.
For URL mode, this field is omitted.
"""
class ElicitationRequiredErrorData(BaseModel):
"""Error data for URLElicitationRequiredError.
Servers return this when a request cannot be processed until one or more
URL mode elicitations are completed.
"""
elicitations: list[ElicitRequestURLParams]
"""List of URL mode elicitations that must be completed."""
model_config = ConfigDict(extra="allow")
class ClientResult(RootModel[EmptyResult | CreateMessageResult | ListRootsResult | ElicitResult]):
pass
@ -1492,6 +1602,7 @@ class ServerNotification(
| ResourceListChangedNotification
| ToolListChangedNotification
| PromptListChangedNotification
| ElicitCompleteNotification
]
):
pass