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:
parent
27279bc157
commit
02b7889929
7 changed files with 1372 additions and 15 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
123
src/mcp/types.py
123
src/mcp/types.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue