"""Elicitation utilities for MCP servers.""" from __future__ import annotations from typing import Any, Generic, Literal, TypeVar from mcp_types import RequestId # Internal surface package; imported as the gate's source of truth for spec-valid property schemas. from mcp_types._v2025_11_25 import PrimitiveSchemaDefinition from pydantic import BaseModel, ValidationError from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue from pydantic_core import core_schema from typing_extensions import TypeAliasType from mcp.server.session import ServerSession ElicitSchemaModelT = TypeVar("ElicitSchemaModelT", bound=BaseModel) class AcceptedElicitation(BaseModel, Generic[ElicitSchemaModelT]): """Result when user accepts the elicitation.""" action: Literal["accept"] = "accept" data: ElicitSchemaModelT class DeclinedElicitation(BaseModel): """Result when user declines the elicitation.""" action: Literal["decline"] = "decline" class CancelledElicitation(BaseModel): """Result when user cancels the elicitation.""" action: Literal["cancel"] = "cancel" ElicitationResult = TypeAliasType( "ElicitationResult", AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation, type_params=(ElicitSchemaModelT,), ) class AcceptedUrlElicitation(BaseModel): """Result when user accepts a URL mode elicitation.""" action: Literal["accept"] = "accept" UrlElicitationResult = AcceptedUrlElicitation | DeclinedElicitation | CancelledElicitation class _ElicitationJsonSchema(GenerateJsonSchema): """JSON-Schema generator that flattens `T | None` to `T` and drops `None` defaults. The spec's `PrimitiveSchemaDefinition` admits no `anyOf` or null type; an optional field is expressed by leaving it out of `required`, which pydantic already does for any field with a default. """ def nullable_schema(self, schema: core_schema.NullableSchema) -> JsonSchemaValue: return self.generate_inner(schema["schema"]) def default_schema(self, schema: core_schema.WithDefaultSchema) -> JsonSchemaValue: result = super().default_schema(schema) if result.get("default") is None: result.pop("default", None) return result def _validate_rendered_properties(json_schema: dict[str, Any]) -> None: """Reject any `properties` entry the spec's `PrimitiveSchemaDefinition` won't accept. Catches whatever the renderer let through that isn't spec-valid: bare `list[str]` (no enum), multi-primitive unions, nested models. """ for field_name, prop in json_schema.get("properties", {}).items(): try: PrimitiveSchemaDefinition.model_validate(prop) except ValidationError: raise TypeError( f"Elicitation schema field {field_name!r} rendered as {prop!r}, " f"which is not a valid PrimitiveSchemaDefinition" ) from None def render_elicitation_schema(schema: type[BaseModel]) -> dict[str, Any]: """Render a model as the spec-valid `requested_schema` for an elicitation. Raises: TypeError: If a field renders as something the spec's `PrimitiveSchemaDefinition` does not accept. """ json_schema = schema.model_json_schema(schema_generator=_ElicitationJsonSchema) _validate_rendered_properties(json_schema) return json_schema async def elicit_with_validation( session: ServerSession, message: str, schema: type[ElicitSchemaModelT], related_request_id: RequestId | None = None, ) -> ElicitationResult[ElicitSchemaModelT]: """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. If the 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. Raises: ValueError: If the client accepted the elicitation without supplying content, or with content that does not match the requested schema. """ json_schema = render_elicitation_schema(schema) result = await session.elicit_form( message=message, requested_schema=json_schema, related_request_id=related_request_id, ) if result.action == "accept": if result.content is None: raise ValueError("Received an accepted elicitation with no content") try: validated_data = schema.model_validate(result.content) except ValidationError as e: raise ValueError( "Received an accepted elicitation whose content does not match the requested schema" ) from e return AcceptedElicitation(data=validated_data) if result.action == "decline": return DeclinedElicitation() return CancelledElicitation() 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}")