-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathelicitation.py
More file actions
190 lines (143 loc) · 6.85 KB
/
Copy pathelicitation.py
File metadata and controls
190 lines (143 loc) · 6.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
"""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}")