forked from modelcontextprotocol/python-sdk
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_elicitation.py
More file actions
299 lines (227 loc) · 14.1 KB
/
Copy pathtest_elicitation.py
File metadata and controls
299 lines (227 loc) · 14.1 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
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
"""`docs/handlers/elicitation.md`: every claim the page makes, proved against the real SDK."""
from typing import Literal
import pytest
from inline_snapshot import snapshot
from mcp_types import (
ElicitCompleteNotification,
ElicitRequestFormParams,
ElicitRequestParams,
ElicitRequestURLParams,
ElicitResult,
TextContent,
)
from pydantic import BaseModel
from docs_src.elicitation import tutorial001, tutorial002, tutorial003, tutorial004
from mcp import Client, MCPError
from mcp.client import ClientRequestContext
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_an_accepted_answer_resumes_the_tool() -> None:
"""tutorial001: the user's answer comes back into the same call as a validated model."""
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"accept_alternative": True, "date": "2025-12-26"})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
assert not result.is_error
assert result.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-26.")]
async def test_an_alternative_that_is_also_full_is_asked_about_again() -> None:
"""tutorial001: the accepted date goes back through `book_table`, so a full date is re-asked, not booked."""
asked: list[str] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
asked.append(params.message)
date = "2025-12-25" if len(asked) == 1 else "2025-12-27"
return ElicitResult(action="accept", content={"accept_alternative": True, "date": date})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
assert result.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-27.")]
assert asked == [
"No tables for 2 on 2025-12-25. Would you like to try another date?",
"No tables for 2 on 2025-12-25. Would you like to try another date?",
]
async def test_the_client_receives_the_message_and_the_generated_schema() -> None:
"""tutorial001: form mode sends your message plus a JSON Schema built from the Pydantic model."""
received: list[ElicitRequestParams] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept", content={"accept_alternative": False})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
(params,) = received
assert isinstance(params, ElicitRequestFormParams)
assert params.message == "No tables for 2 on 2025-12-25. Would you like to try another date?"
assert params.requested_schema == snapshot(
{
"properties": {
"accept_alternative": {
"description": "Try another date?",
"title": "Accept Alternative",
"type": "boolean",
},
"date": {
"default": "2025-12-26",
"description": "Alternative date (YYYY-MM-DD)",
"title": "Date",
"type": "string",
},
},
"required": ["accept_alternative"],
"title": "AlternativeDate",
"type": "object",
}
)
async def test_decline_and_cancel_are_ordinary_return_values() -> None:
"""tutorial001: a refusal is not an error; the tool sees the action and answers the model normally."""
async def on_decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="decline")
async def on_cancel(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="cancel")
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_decline) as client:
declined = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_cancel) as client:
cancelled = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
assert declined.content == [TextContent(type="text", text="No booking made.")]
assert not declined.is_error
assert cancelled.content == [TextContent(type="text", text="No booking made.")]
async def test_a_tool_that_does_not_ask_needs_nothing_from_the_client() -> None:
"""tutorial001: the elicitation only happens on the path that needs it."""
async with Client(tutorial001.mcp, mode="legacy") as client:
result = await client.call_tool("book_table", {"date": "2025-12-30", "party_size": 4})
assert result.content == [TextContent(type="text", text="Booked a table for 4 on 2025-12-30.")]
async def test_an_answer_that_does_not_match_the_schema_never_reaches_the_tool_code() -> None:
"""`!!! tip`: the client's content is validated against the model; a mismatch fails the call."""
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"accept_alternative": "maybe"})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert "does not match the requested schema" in result.content[0].text
class Address(BaseModel):
city: str
class Applicant(BaseModel):
name: str
address: Address
class Seating(BaseModel):
area: Literal["inside", "terrace"]
schema_gate_server = MCPServer("Bistro")
"""The `!!! warning` claims: what the elicitation schema gate accepts and rejects."""
@schema_gate_server.tool()
async def sign_up(ctx: Context) -> str:
"""Collect the new customer's details."""
return str(await ctx.elicit(message="Who are you?", schema=Applicant))
@schema_gate_server.tool()
async def choose_seating(ctx: Context) -> str:
"""Ask where the party wants to sit."""
result = await ctx.elicit(message="Where would you like to sit?", schema=Seating)
assert result.action == "accept"
return result.data.area
async def test_a_nested_model_is_rejected_before_anything_is_sent() -> None:
"""`!!! warning`: a non-primitive field raises `TypeError` inside `ctx.elicit`, with this exact message."""
async with Client(schema_gate_server, mode="legacy") as client:
result = await client.call_tool("sign_up", {})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == (
"Error executing tool sign_up: Elicitation schema field 'address' rendered as "
"{'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition"
)
async def test_a_literal_field_passes_the_gate_as_an_enum() -> None:
"""`!!! warning`: a `Literal[...]` of strings renders as a JSON Schema `enum`, which the spec allows."""
received: list[ElicitRequestParams] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept", content={"area": "terrace"})
async with Client(schema_gate_server, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("choose_seating", {})
assert result.content == [TextContent(type="text", text="terrace")]
(params,) = received
assert isinstance(params, ElicitRequestFormParams)
assert params.requested_schema["properties"]["area"] == snapshot(
{"enum": ["inside", "terrace"], "title": "Area", "type": "string"}
)
async def test_url_mode_sends_a_url_and_gets_consent_back_not_data() -> None:
"""tutorial002: the client receives the URL and the elicitation id; only the action comes back."""
received: list[ElicitRequestParams] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept")
async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("pay_deposit", {"booking_id": "b42"})
assert result.content == [TextContent(type="text", text="Complete the payment in your browser.")]
(params,) = received
assert isinstance(params, ElicitRequestURLParams)
assert params.url == "https://pay.example.com/deposit/b42"
assert params.elicitation_id == "deposit-b42"
async def test_a_declined_url_elicitation_is_an_ordinary_return_value() -> None:
"""tutorial002: the tool decides what a refusal means."""
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="decline")
async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("pay_deposit", {"booking_id": "b42"})
assert result.content == [TextContent(type="text", text="No deposit taken. The booking expires in one hour.")]
async def test_send_elicit_complete_notifies_the_client_with_the_same_id() -> None:
"""tutorial002: `send_elicit_complete` emits `notifications/elicitation/complete`."""
notifications: list[object] = []
async def on_message(message: object) -> None:
notifications.append(message)
async with Client(tutorial002.mcp, mode="legacy", message_handler=on_message) as client:
result = await client.call_tool("confirm_deposit", {"booking_id": "b42"})
assert result.content == [TextContent(type="text", text="Deposit received for booking b42.")]
(notification,) = notifications
assert isinstance(notification, ElicitCompleteNotification)
assert notification.params.elicitation_id == "deposit-b42"
async def test_the_docs_client_callback_handles_both_modes() -> None:
"""tutorial003: one `elicitation_callback` answers the form and the URL consent."""
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=tutorial003.handle_elicitation) as client:
booked = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=tutorial003.handle_elicitation) as client:
paid = await client.call_tool("pay_deposit", {"booking_id": "b42"})
assert booked.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-27.")]
assert paid.content == [TextContent(type="text", text="Complete the payment in your browser.")]
async def test_a_client_without_the_callback_cannot_be_asked() -> None:
"""`!!! check`: no `elicitation_callback` means no `elicitation` capability; the call is a protocol error."""
async with Client(tutorial001.mcp, mode="legacy") as client:
with pytest.raises(MCPError, match="Elicitation not supported"):
await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
async def test_resolver_asks_only_when_the_folder_is_not_empty() -> None:
"""tutorial004: `confirm_delete` resolves an empty folder directly and elicits otherwise."""
tutorial004._FOLDERS.update({"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]})
asked: list[str] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
assert isinstance(params, ElicitRequestFormParams)
asked.append(params.message)
return ElicitResult(action="accept", content={"ok": True})
async with Client(tutorial004.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
empty = await client.call_tool("delete_folder", {"path": "/tmp/empty"})
non_empty = await client.call_tool("delete_folder", {"path": "/tmp/project"})
assert empty.content == [TextContent(type="text", text="deleted /tmp/empty")]
assert non_empty.content == [TextContent(type="text", text="deleted /tmp/project")]
assert asked == ["/tmp/project has 2 file(s). Delete anyway?"] # the empty folder was not queried
async def test_the_resolved_parameter_is_hidden_from_the_tool_schema() -> None:
"""tutorial004: the `Resolve`-filled parameter never appears in the client-facing input schema."""
async with Client(tutorial004.mcp, mode="legacy") as client:
(tool,) = (await client.list_tools()).tools
assert tool.name == "delete_folder"
assert set(tool.input_schema["properties"]) == {"path"}
@pytest.mark.parametrize(
("action", "content", "expected"),
[
("accept", {"ok": False}, "kept the folder"),
("decline", None, "declined: folder not deleted"),
("cancel", None, "cancelled: folder not deleted"),
],
)
async def test_the_tool_branches_on_every_elicitation_outcome(
action: Literal["accept", "decline", "cancel"],
content: dict[str, str | int | float | bool | list[str] | None] | None,
expected: str,
) -> None:
"""tutorial004: annotating the result union lets the tool handle accept/decline/cancel."""
tutorial004._FOLDERS["/tmp/project"] = ["main.py", "README.md"]
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action=action, content=content)
async with Client(tutorial004.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("delete_folder", {"path": "/tmp/project"})
assert result.content == [TextContent(type="text", text=expected)]