Jeremiah Lowin commited on
Commit
fb12cad
·
1 Parent(s): 815bac1

Support constrained choice

Browse files
src/fastmcp/server/context.py CHANGED
@@ -6,7 +6,8 @@ from collections.abc import Generator
6
  from contextlib import contextmanager
7
  from contextvars import ContextVar, Token
8
  from dataclasses import dataclass
9
- from typing import TypeVar, cast
 
10
 
11
  from mcp import LoggingLevel, ServerSession
12
  from mcp.server.lowlevel.helper_types import ReadResourceContents
@@ -314,7 +315,7 @@ class Context:
314
  async def elicit(
315
  self,
316
  message: str,
317
- response_type: type[T] | None = None,
318
  ) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation:
319
  """
320
  Send an elicitation request to the client and await the response.
@@ -338,10 +339,29 @@ class Context:
338
  if response_type is None:
339
  response_type = str # type: ignore
340
 
341
- if response_type in {bool, int, float, str}:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  response_type = ScalarElicitationType[response_type] # type: ignore
 
 
 
 
 
343
 
344
- requested_schema = get_elicitation_schema(response_type) # type: ignore
345
 
346
  result = await self.session.elicit(
347
  message=message,
 
6
  from contextlib import contextmanager
7
  from contextvars import ContextVar, Token
8
  from dataclasses import dataclass
9
+ from enum import Enum
10
+ from typing import Literal, TypeVar, cast, get_origin
11
 
12
  from mcp import LoggingLevel, ServerSession
13
  from mcp.server.lowlevel.helper_types import ReadResourceContents
 
315
  async def elicit(
316
  self,
317
  message: str,
318
+ response_type: type[T] | list[str] | None = None,
319
  ) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation:
320
  """
321
  Send an elicitation request to the client and await the response.
 
339
  if response_type is None:
340
  response_type = str # type: ignore
341
 
342
+ # if the user provided a list of strings, treat it as a Literal
343
+ if isinstance(response_type, list):
344
+ if not all(isinstance(item, str) for item in response_type):
345
+ raise ValueError(
346
+ "List of options must be a list of strings. Received: "
347
+ f"{response_type}"
348
+ )
349
+ # Convert list of options to Literal type and wrap
350
+ choice_literal = Literal[*tuple(response_type)] # type: ignore
351
+ response_type = ScalarElicitationType[choice_literal] # type: ignore
352
+ # if the user provided a primitive scalar, wrap it in an object schema
353
+ elif response_type in {bool, int, float, str}:
354
+ response_type = ScalarElicitationType[response_type] # type: ignore
355
+ # if the user provided a Literal type, wrap it in an object schema
356
+ elif get_origin(response_type) is Literal:
357
  response_type = ScalarElicitationType[response_type] # type: ignore
358
+ # if the user provided an Enum type, wrap it in an object schema
359
+ elif isinstance(response_type, type) and issubclass(response_type, Enum):
360
+ response_type = ScalarElicitationType[response_type] # type: ignore
361
+
362
+ response_type = cast(type[T], response_type)
363
 
364
+ requested_schema = get_elicitation_schema(response_type)
365
 
366
  result = await self.session.elicit(
367
  message=message,
tests/client/test_elicitation.py CHANGED
@@ -3,6 +3,8 @@ from enum import Enum
3
  from typing import Literal
4
 
5
  import pytest
 
 
6
 
7
  from fastmcp import Context, FastMCP
8
  from fastmcp.client.client import Client
@@ -159,23 +161,122 @@ async def test_elicitation_cancel_action():
159
  assert result.data == "Request was canceled"
160
 
161
 
162
- async def test_elicitation_number_schema():
163
- """Test elicitation with number schema."""
164
- mcp = FastMCP("TestServer")
 
165
 
166
- @mcp.tool
167
- async def get_age(context: Context) -> str:
168
- result = await context.elicit(message="How old are you?", response_type=int)
169
- if result.action == "accept":
170
- return f"You are {result.data} years old"
171
- return "No age provided"
172
 
173
- async def elicitation_handler(message, response_type, params, ctx):
174
- return ElicitResult(action="accept", content=response_type(value=25))
175
 
176
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
177
- result = await client.call_tool("get_age", {})
178
- assert result.data == "You are 25 years old"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
 
181
  async def test_elicitation_handler_error():
@@ -237,29 +338,48 @@ async def test_elicitation_multiple_calls():
237
  assert call_count == 2
238
 
239
 
240
- async def test_dataclass_response_type():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  """Test elicitation with dataclass response type."""
242
  mcp = FastMCP("TestServer")
243
 
244
- @dataclass
245
- class UserInfo:
246
- name: str
247
- age: int
248
-
249
  @mcp.tool
250
  async def get_user_info(context: Context) -> str:
251
  result = await context.elicit(
252
- message="Please provide your information", response_type=UserInfo
253
  )
254
  if result.action == "accept":
255
- return f"User: {result.data.name}, age: {result.data.age}"
 
 
 
256
  return "No user info provided"
257
 
258
  async def elicitation_handler(message, response_type, params, ctx):
259
  # Verify we get the dataclass type
260
  assert (
261
  TypeAdapter(response_type).json_schema()
262
- == TypeAdapter(UserInfo).json_schema()
263
  )
264
 
265
  # Verify the schema has the dataclass fields (available in params)
@@ -387,73 +507,72 @@ class TestValidation:
387
  )
388
 
389
 
390
- async def test_pattern_matching_accept():
391
- """Test pattern matching with AcceptedElicitation."""
392
- mcp = FastMCP("TestServer")
393
-
394
- @mcp.tool
395
- async def pattern_match_tool(context: Context) -> str:
396
- result = await context.elicit("Enter your name:", response_type=str)
397
-
398
- match result:
399
- case AcceptedElicitation(data=name):
400
- return f"Hello {name}!"
401
- case DeclinedElicitation():
402
- return "You declined"
403
- case CancelledElicitation():
404
- return "Cancelled"
405
-
406
- async def elicitation_handler(message, response_type, params, ctx):
407
- return ElicitResult(action="accept", content={"value": "Alice"})
408
-
409
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
410
- result = await client.call_tool("pattern_match_tool", {})
411
- assert result.data == "Hello Alice!"
412
-
413
-
414
- async def test_pattern_matching_decline():
415
- """Test pattern matching with DeclinedElicitation."""
416
- mcp = FastMCP("TestServer")
417
-
418
- @mcp.tool
419
- async def pattern_match_tool(context: Context) -> str:
420
- result = await context.elicit("Enter your name:", response_type=str)
421
-
422
- match result:
423
- case AcceptedElicitation(data=name):
424
- return f"Hello {name}!"
425
- case DeclinedElicitation():
426
- return "You declined"
427
- case CancelledElicitation():
428
- return "Cancelled"
429
-
430
- async def elicitation_handler(message, response_type, params, ctx):
431
- return ElicitResult(action="decline")
432
-
433
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
434
- result = await client.call_tool("pattern_match_tool", {})
435
- assert result.data == "You declined"
436
-
437
-
438
- async def test_pattern_matching_cancel():
439
- """Test pattern matching with CancelledElicitation."""
440
- mcp = FastMCP("TestServer")
441
-
442
- @mcp.tool
443
- async def pattern_match_tool(context: Context) -> str:
444
- result = await context.elicit("Enter your name:", response_type=str)
445
-
446
- match result:
447
- case AcceptedElicitation(data=name):
448
- return f"Hello {name}!"
449
- case DeclinedElicitation():
450
- return "You declined"
451
- case CancelledElicitation():
452
- return "Cancelled"
453
-
454
- async def elicitation_handler(message, response_type, params, ctx):
455
- return ElicitResult(action="cancel")
456
-
457
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
458
- result = await client.call_tool("pattern_match_tool", {})
459
- assert result.data == "Cancelled"
 
3
  from typing import Literal
4
 
5
  import pytest
6
+ from pydantic import BaseModel
7
+ from typing_extensions import TypedDict
8
 
9
  from fastmcp import Context, FastMCP
10
  from fastmcp.client.client import Client
 
161
  assert result.data == "Request was canceled"
162
 
163
 
164
+ class TestScalarResponseTypes:
165
+ async def test_elicitation_str_response(self):
166
+ """Test elicitation with string schema."""
167
+ mcp = FastMCP("TestServer")
168
 
169
+ @mcp.tool
170
+ async def my_tool(context: Context) -> str:
171
+ result = await context.elicit(message="", response_type=str)
172
+ return result.data # type: ignore[attr-defined]
 
 
173
 
174
+ async def elicitation_handler(message, response_type, params, ctx):
175
+ return ElicitResult(action="accept", content={"value": "hello"})
176
 
177
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
178
+ result = await client.call_tool("my_tool", {})
179
+ assert result.data == "hello"
180
+
181
+ async def test_elicitation_int_response(self):
182
+ """Test elicitation with number schema."""
183
+ mcp = FastMCP("TestServer")
184
+
185
+ @mcp.tool
186
+ async def my_tool(context: Context) -> int:
187
+ result = await context.elicit(message="", response_type=int)
188
+ return result.data # type: ignore[attr-defined]
189
+
190
+ async def elicitation_handler(message, response_type, params, ctx):
191
+ return ElicitResult(action="accept", content={"value": 42})
192
+
193
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
194
+ result = await client.call_tool("my_tool", {})
195
+ assert result.data == 42
196
+
197
+ async def test_elicitation_float_response(self):
198
+ """Test elicitation with number schema."""
199
+ mcp = FastMCP("TestServer")
200
+
201
+ @mcp.tool
202
+ async def my_tool(context: Context) -> float:
203
+ result = await context.elicit(message="", response_type=float)
204
+ return result.data # type: ignore[attr-defined]
205
+
206
+ async def elicitation_handler(message, response_type, params, ctx):
207
+ return ElicitResult(action="accept", content={"value": 3.14})
208
+
209
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
210
+ result = await client.call_tool("my_tool", {})
211
+ assert result.data == 3.14
212
+
213
+ async def test_elicitation_bool_response(self):
214
+ """Test elicitation with boolean schema."""
215
+ mcp = FastMCP("TestServer")
216
+
217
+ @mcp.tool
218
+ async def my_tool(context: Context) -> bool:
219
+ result = await context.elicit(message="", response_type=bool)
220
+ return result.data # type: ignore[attr-defined]
221
+
222
+ async def elicitation_handler(message, response_type, params, ctx):
223
+ return ElicitResult(action="accept", content={"value": True})
224
+
225
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
226
+ result = await client.call_tool("my_tool", {})
227
+ assert result.data is True
228
+
229
+ async def test_elicitation_literal_response(self):
230
+ """Test elicitation with literal schema."""
231
+ mcp = FastMCP("TestServer")
232
+
233
+ @mcp.tool
234
+ async def my_tool(context: Context) -> Literal["x", "y"]:
235
+ result = await context.elicit(message="", response_type=Literal["x", "y"]) # type: ignore
236
+ return result.data # type: ignore[attr-defined]
237
+
238
+ async def elicitation_handler(message, response_type, params, ctx):
239
+ return ElicitResult(action="accept", content={"value": "x"})
240
+
241
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
242
+ result = await client.call_tool("my_tool", {})
243
+ assert result.data == "x"
244
+
245
+ async def test_elicitation_enum_response(self):
246
+ """Test elicitation with enum schema."""
247
+ mcp = FastMCP("TestServer")
248
+
249
+ class ResponseEnum(Enum):
250
+ X = "x"
251
+ Y = "y"
252
+
253
+ @mcp.tool
254
+ async def my_tool(context: Context) -> ResponseEnum:
255
+ result = await context.elicit(message="", response_type=ResponseEnum)
256
+ return result.data # type: ignore[attr-defined]
257
+
258
+ async def elicitation_handler(message, response_type, params, ctx):
259
+ return ElicitResult(action="accept", content={"value": "x"})
260
+
261
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
262
+ result = await client.call_tool("my_tool", {})
263
+ assert result.data == "x"
264
+
265
+ async def test_elicitation_list_response(self):
266
+ """Test elicitation with list schema."""
267
+ mcp = FastMCP("TestServer")
268
+
269
+ @mcp.tool
270
+ async def my_tool(context: Context) -> str:
271
+ result = await context.elicit(message="", response_type=["x", "y"])
272
+ return result.data # type: ignore[attr-defined]
273
+
274
+ async def elicitation_handler(message, response_type, params, ctx):
275
+ return ElicitResult(action="accept", content={"value": "x"})
276
+
277
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
278
+ result = await client.call_tool("my_tool", {})
279
+ assert result.data == "x"
280
 
281
 
282
  async def test_elicitation_handler_error():
 
338
  assert call_count == 2
339
 
340
 
341
+ @dataclass
342
+ class UserInfo:
343
+ name: str
344
+ age: int
345
+
346
+
347
+ class UserInfoTypedDict(TypedDict):
348
+ name: str
349
+ age: int
350
+
351
+
352
+ class UserInfoPydantic(BaseModel):
353
+ name: str
354
+ age: int
355
+
356
+
357
+ @pytest.mark.parametrize(
358
+ "structured_type", [UserInfo, UserInfoTypedDict, UserInfoPydantic]
359
+ )
360
+ async def test_structured_response_type(
361
+ structured_type: type[UserInfo | UserInfoTypedDict | UserInfoPydantic],
362
+ ):
363
  """Test elicitation with dataclass response type."""
364
  mcp = FastMCP("TestServer")
365
 
 
 
 
 
 
366
  @mcp.tool
367
  async def get_user_info(context: Context) -> str:
368
  result = await context.elicit(
369
+ message="Please provide your information", response_type=structured_type
370
  )
371
  if result.action == "accept":
372
+ if isinstance(result.data, dict):
373
+ return f"User: {result.data['name']}, age: {result.data['age']}"
374
+ else:
375
+ return f"User: {result.data.name}, age: {result.data.age}"
376
  return "No user info provided"
377
 
378
  async def elicitation_handler(message, response_type, params, ctx):
379
  # Verify we get the dataclass type
380
  assert (
381
  TypeAdapter(response_type).json_schema()
382
+ == TypeAdapter(structured_type).json_schema()
383
  )
384
 
385
  # Verify the schema has the dataclass fields (available in params)
 
507
  )
508
 
509
 
510
+ class TestPatternMatching:
511
+ async def test_pattern_matching_accept(self):
512
+ """Test pattern matching with AcceptedElicitation."""
513
+ mcp = FastMCP("TestServer")
514
+
515
+ @mcp.tool
516
+ async def pattern_match_tool(context: Context) -> str:
517
+ result = await context.elicit("Enter your name:", response_type=str)
518
+
519
+ match result:
520
+ case AcceptedElicitation(data=name):
521
+ return f"Hello {name}!"
522
+ case DeclinedElicitation():
523
+ return "You declined"
524
+ case CancelledElicitation():
525
+ return "Cancelled"
526
+
527
+ async def elicitation_handler(message, response_type, params, ctx):
528
+ return ElicitResult(action="accept", content={"value": "Alice"})
529
+
530
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
531
+ result = await client.call_tool("pattern_match_tool", {})
532
+ assert result.data == "Hello Alice!"
533
+
534
+ async def test_pattern_matching_decline(self):
535
+ """Test pattern matching with DeclinedElicitation."""
536
+ mcp = FastMCP("TestServer")
537
+
538
+ @mcp.tool
539
+ async def pattern_match_tool(context: Context) -> str:
540
+ result = await context.elicit("Enter your name:", response_type=str)
541
+
542
+ match result:
543
+ case AcceptedElicitation(data=name):
544
+ return f"Hello {name}!"
545
+ case DeclinedElicitation():
546
+ return "You declined"
547
+ case CancelledElicitation():
548
+ return "Cancelled"
549
+
550
+ async def elicitation_handler(message, response_type, params, ctx):
551
+ return ElicitResult(action="decline")
552
+
553
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
554
+ result = await client.call_tool("pattern_match_tool", {})
555
+ assert result.data == "You declined"
556
+
557
+ async def test_pattern_matching_cancel(self):
558
+ """Test pattern matching with CancelledElicitation."""
559
+ mcp = FastMCP("TestServer")
560
+
561
+ @mcp.tool
562
+ async def pattern_match_tool(context: Context) -> str:
563
+ result = await context.elicit("Enter your name:", response_type=str)
564
+
565
+ match result:
566
+ case AcceptedElicitation(data=name):
567
+ return f"Hello {name}!"
568
+ case DeclinedElicitation():
569
+ return "You declined"
570
+ case CancelledElicitation():
571
+ return "Cancelled"
572
+
573
+ async def elicitation_handler(message, response_type, params, ctx):
574
+ return ElicitResult(action="cancel")
575
+
576
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
577
+ result = await client.call_tool("pattern_match_tool", {})
578
+ assert result.data == "Cancelled"