Jeremiah Lowin commited on
Commit
dbf9272
·
1 Parent(s): 4e5dae3

Support "no response" elicitation requests

Browse files
docs/clients/elicitation.mdx CHANGED
@@ -65,7 +65,7 @@ The elicitation handler receives four parameters:
65
  </ResponseField>
66
 
67
  <ResponseField name="response_type" type="type">
68
- A Python dataclass type that FastMCP created from the server's JSON schema. Use this to construct your response with proper typing and IDE support.
69
  </ResponseField>
70
 
71
  <ResponseField name="params" type="ElicitRequestParams">
 
65
  </ResponseField>
66
 
67
  <ResponseField name="response_type" type="type">
68
+ A Python dataclass type that FastMCP created from the server's JSON schema. Use this to construct your response with proper typing and IDE support. If the server requests an empty object (indicating no response), this will be `None`.
69
  </ResponseField>
70
 
71
  <ResponseField name="params" type="ElicitRequestParams">
docs/servers/elicitation.mdx CHANGED
@@ -68,8 +68,8 @@ async def collect_user_info(ctx: Context) -> str:
68
  The prompt message to display to the user
69
  </ResponseField>
70
 
71
- <ResponseField name="response_type" type="type" default="str">
72
- The Python type defining the expected response structure (dataclass, primitive type, etc.) Note that elicitation responses are subject to a restricted subset of JSON Schema types. See [Supported Response Types](#supported-response-types) for more details.
73
  </ResponseField>
74
  </Expandable>
75
 
@@ -140,7 +140,7 @@ The server must send a schema to the client indicating the type of data it expec
140
 
141
  The MCP spec only supports a limited subset of JSON Schema types for elicitation responses. Specifically, it only supports JSON **objects** with **primitive** properties including `string`, `number` (or `integer`), `boolean` and `enum` fields.
142
 
143
- FastMCP makes it easy to request a broader range of types, including scalars (e.g. `str`), by automatically wrapping them in MCP-compatible object schemas.
144
 
145
 
146
  ### Scalar Types
@@ -184,6 +184,22 @@ async def pick_a_boolean(ctx: Context) -> str:
184
  ```
185
  </CodeGroup>
186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  ### Constrained Options
188
 
189
  Often you'll want to constrain the user's response to a specific set of values. You can do this by using a `Literal` type or a Python enum as the response type, or by passing a list of strings to the `response_type` parameter as a convenient shortcut.
 
68
  The prompt message to display to the user
69
  </ResponseField>
70
 
71
+ <ResponseField name="response_type" type="type" default="None">
72
+ The Python type defining the expected response structure (dataclass, primitive type, etc.) Note that elicitation responses are subject to a restricted subset of JSON Schema types. See [Supported Response Types](#supported-response-types) for more details.
73
  </ResponseField>
74
  </Expandable>
75
 
 
140
 
141
  The MCP spec only supports a limited subset of JSON Schema types for elicitation responses. Specifically, it only supports JSON **objects** with **primitive** properties including `string`, `number` (or `integer`), `boolean` and `enum` fields.
142
 
143
+ FastMCP makes it easy to request a broader range of types, including scalars (e.g. `str`) or no response at all, by automatically wrapping them in MCP-compatible object schemas.
144
 
145
 
146
  ### Scalar Types
 
184
  ```
185
  </CodeGroup>
186
 
187
+ ### No Response
188
+
189
+ Sometimes, the goal of an elicitation is to simply get a user to approve or reject an action. In this case, you can pass `None` as the response type to indicate that no response is expected. In order to comply with the MCP spec, the client will see a schema requesting an empty object in response. In this case, the `data` field of the `ElicitationResult` object will be `None` when the user accepts the elicitation.
190
+
191
+ ```python {4} title="No response"
192
+ @mcp.tool
193
+ async def approve_action(ctx: Context) -> str:
194
+ """Approve an action."""
195
+ result = await ctx.elicit("Approve this action?", response_type=None)
196
+
197
+ if result.action == "accept":
198
+ return do_action()
199
+ else:
200
+ raise ValueError("Action rejected")
201
+ ```
202
+
203
  ### Constrained Options
204
 
205
  Often you'll want to constrain the user's response to a specific set of values. You can do this by using a `Literal` type or a Python enum as the response type, or by passing a list of strings to the `response_type` parameter as a convenient shortcut.
src/fastmcp/client/elicitation.py CHANGED
@@ -41,7 +41,10 @@ def create_elicitation_callback(
41
  params: ElicitRequestParams,
42
  ) -> MCPElicitResult | mcp.types.ErrorData:
43
  try:
44
- response_type = json_schema_to_type(params.requestedSchema)
 
 
 
45
 
46
  result = await elicitation_handler(
47
  params.message, response_type, params, context
 
41
  params: ElicitRequestParams,
42
  ) -> MCPElicitResult | mcp.types.ErrorData:
43
  try:
44
+ if params.requestedSchema == {"type": "object", "properties": {}}:
45
+ response_type = None
46
+ else:
47
+ response_type = json_schema_to_type(params.requestedSchema)
48
 
49
  result = await elicitation_handler(
50
  params.message, response_type, params, context
src/fastmcp/server/context.py CHANGED
@@ -7,7 +7,7 @@ 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
@@ -312,11 +312,49 @@ class Context:
312
 
313
  return result.content
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
322
 
@@ -330,6 +368,10 @@ class Context:
330
  "value" field will be generated for the MCP interaction and
331
  automatically deconstructed into the primitive type upon response.
332
 
 
 
 
 
333
  Args:
334
  message: A human-readable message explaining what information is needed
335
  response_type: The type of the response, which should be a primitive
@@ -337,48 +379,56 @@ class Context:
337
  object schema with a single "value" field will be generated.
338
  """
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,
368
- requestedSchema=requested_schema,
369
  related_request_id=self.request_id,
370
  )
371
 
372
- if result.action == "accept" and result.content:
373
- type_adapter = get_cached_typeadapter(response_type)
374
- validated_data = cast(
375
- T | ScalarElicitationType[T],
376
- type_adapter.validate_python(result.content),
377
- )
378
- if isinstance(validated_data, ScalarElicitationType):
379
- return AcceptedElicitation[T](data=validated_data.value)
 
 
 
 
 
 
 
 
380
  else:
381
- return AcceptedElicitation[T](data=validated_data)
382
  elif result.action == "decline":
383
  return DeclinedElicitation()
384
  elif result.action == "cancel":
 
7
  from contextvars import ContextVar, Token
8
  from dataclasses import dataclass
9
  from enum import Enum
10
+ from typing import Any, Literal, TypeVar, cast, get_origin, overload
11
 
12
  from mcp import LoggingLevel, ServerSession
13
  from mcp.server.lowlevel.helper_types import ReadResourceContents
 
312
 
313
  return result.content
314
 
315
+ @overload
316
+ async def elicit(
317
+ self,
318
+ message: str,
319
+ response_type: None,
320
+ ) -> (
321
+ AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation
322
+ ): ...
323
+
324
+ """When response_type is None, the accepted elicitaiton will contain an
325
+ empty dict"""
326
+
327
+ @overload
328
+ async def elicit(
329
+ self,
330
+ message: str,
331
+ response_type: type[T],
332
+ ) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ...
333
+
334
+ """When response_type is not None, the accepted elicitaiton will contain the
335
+ response data"""
336
+
337
+ @overload
338
+ async def elicit(
339
+ self,
340
+ message: str,
341
+ response_type: list[str],
342
+ ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
343
+
344
+ """When response_type is a list of strings, the accepted elicitaiton will
345
+ contain the selected string response"""
346
+
347
  async def elicit(
348
  self,
349
  message: str,
350
  response_type: type[T] | list[str] | None = None,
351
+ ) -> (
352
+ AcceptedElicitation[T]
353
+ | AcceptedElicitation[dict[str, Any]]
354
+ | AcceptedElicitation[str]
355
+ | DeclinedElicitation
356
+ | CancelledElicitation
357
+ ):
358
  """
359
  Send an elicitation request to the client and await the response.
360
 
 
368
  "value" field will be generated for the MCP interaction and
369
  automatically deconstructed into the primitive type upon response.
370
 
371
+ If the response_type is None, the generated schema will be that of an
372
+ empty object in order to comply with the MCP protocol requirements.
373
+ Clients must send an empty object ("{}")in response.
374
+
375
  Args:
376
  message: A human-readable message explaining what information is needed
377
  response_type: The type of the response, which should be a primitive
 
379
  object schema with a single "value" field will be generated.
380
  """
381
  if response_type is None:
382
+ schema = {"type": "object", "properties": {}}
383
+ else:
384
+ # if the user provided a list of strings, treat it as a Literal
385
+ if isinstance(response_type, list):
386
+ if not all(isinstance(item, str) for item in response_type):
387
+ raise ValueError(
388
+ "List of options must be a list of strings. Received: "
389
+ f"{response_type}"
390
+ )
391
+ # Convert list of options to Literal type and wrap
392
+ choice_literal = Literal[tuple(response_type)] # type: ignore
393
+ response_type = ScalarElicitationType[choice_literal] # type: ignore
394
+ # if the user provided a primitive scalar, wrap it in an object schema
395
+ elif response_type in {bool, int, float, str}:
396
+ response_type = ScalarElicitationType[response_type] # type: ignore
397
+ # if the user provided a Literal type, wrap it in an object schema
398
+ elif get_origin(response_type) is Literal:
399
+ response_type = ScalarElicitationType[response_type] # type: ignore
400
+ # if the user provided an Enum type, wrap it in an object schema
401
+ elif isinstance(response_type, type) and issubclass(response_type, Enum):
402
+ response_type = ScalarElicitationType[response_type] # type: ignore
403
+
404
+ response_type = cast(type[T], response_type)
405
+
406
+ schema = get_elicitation_schema(response_type)
407
 
408
  result = await self.session.elicit(
409
  message=message,
410
+ requestedSchema=schema,
411
  related_request_id=self.request_id,
412
  )
413
 
414
+ if result.action == "accept":
415
+ if response_type is not None:
416
+ type_adapter = get_cached_typeadapter(response_type)
417
+ validated_data = cast(
418
+ T | ScalarElicitationType[T],
419
+ type_adapter.validate_python(result.content),
420
+ )
421
+ if isinstance(validated_data, ScalarElicitationType):
422
+ return AcceptedElicitation[T](data=validated_data.value)
423
+ else:
424
+ return AcceptedElicitation[T](data=validated_data)
425
+ elif result.content:
426
+ raise ValueError(
427
+ "Elicitation expected an empty response, but received: "
428
+ f"{result.content}"
429
+ )
430
  else:
431
+ return AcceptedElicitation[dict[str, Any]](data={})
432
  elif result.action == "decline":
433
  return DeclinedElicitation()
434
  elif result.action == "cancel":
src/fastmcp/server/elicitation.py CHANGED
@@ -81,11 +81,6 @@ def validate_elicitation_json_schema(schema: dict[str, Any]) -> None:
81
  )
82
 
83
  properties = schema.get("properties", {})
84
- if not properties:
85
- raise TypeError(
86
- "Elicitation schema must have at least one property. "
87
- "Empty object schemas are not allowed."
88
- )
89
 
90
  for prop_name, prop_schema in properties.items():
91
  prop_type = prop_schema.get("type")
 
81
  )
82
 
83
  properties = schema.get("properties", {})
 
 
 
 
 
84
 
85
  for prop_name, prop_schema in properties.items():
86
  prop_type = prop_schema.get("type")
src/fastmcp/utilities/components.py CHANGED
@@ -77,7 +77,3 @@ class FastMCPComponent(FastMCPBaseModel):
77
  def disable(self) -> None:
78
  """Disable the component."""
79
  self.enabled = False
80
-
81
- def get_display_name(self) -> str:
82
- """Get the display name for this component, preferring title over name."""
83
- return self.title if self.title is not None else self.name
 
77
  def disable(self) -> None:
78
  """Disable the component."""
79
  self.enabled = False
 
 
 
 
tests/client/test_elicitation.py CHANGED
@@ -1,8 +1,9 @@
1
  from dataclasses import asdict, dataclass
2
  from enum import Enum
3
- from typing import Literal
4
 
5
  import pytest
 
6
  from pydantic import BaseModel
7
  from typing_extensions import TypedDict
8
 
@@ -14,6 +15,7 @@ from fastmcp.server.elicitation import (
14
  AcceptedElicitation,
15
  CancelledElicitation,
16
  DeclinedElicitation,
 
17
  )
18
  from fastmcp.utilities.types import TypeAdapter
19
 
@@ -79,30 +81,6 @@ async def test_elicitation_decline(fastmcp_server):
79
  assert result.data == "No name provided."
80
 
81
 
82
- async def test_default_response_type(fastmcp_server):
83
- """Test elicitation with string content."""
84
- mcp = FastMCP("TestServer")
85
-
86
- @mcp.tool
87
- async def ask_for_color(context: Context) -> str:
88
- result = await context.elicit(
89
- message="What is your favorite color?"
90
- # Default schema should be string
91
- )
92
- if result.action == "accept":
93
- assert isinstance(result.data, str)
94
- return f"Your favorite color is {result.data}!"
95
- return "No color provided"
96
-
97
- async def elicitation_handler(message, response_type, params, ctx):
98
- # Mock user providing their favorite color as string in content dict
99
- return ElicitResult(action="accept", content={"value": "blue"})
100
-
101
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
102
- result = await client.call_tool("ask_for_color", {})
103
- assert result.data == "Your favorite color is blue!"
104
-
105
-
106
  async def test_elicitation_handler_parameters():
107
  """Test that elicitation handler receives correct parameters."""
108
  mcp = FastMCP("TestServer")
@@ -162,6 +140,62 @@ async def test_elicitation_cancel_action():
162
 
163
 
164
  class TestScalarResponseTypes:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  async def test_elicitation_str_response(self):
166
  """Test elicitation with string schema."""
167
  mcp = FastMCP("TestServer")
@@ -262,7 +296,7 @@ class TestScalarResponseTypes:
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
 
@@ -459,21 +493,12 @@ async def test_all_primitive_field_types():
459
  class TestValidation:
460
  async def test_schema_validation_rejects_non_object(self):
461
  """Test that non-object schemas are rejected."""
462
- from fastmcp.server.elicitation import validate_elicitation_json_schema
463
 
464
  with pytest.raises(TypeError, match="must be an object schema"):
465
  validate_elicitation_json_schema({"type": "string"})
466
 
467
- async def test_schema_validation_rejects_empty_object(self):
468
- """Test that object schemas without properties are rejected."""
469
- from fastmcp.server.elicitation import validate_elicitation_json_schema
470
-
471
- with pytest.raises(TypeError, match="must have at least one property"):
472
- validate_elicitation_json_schema({"type": "object"})
473
-
474
  async def test_schema_validation_rejects_nested_objects(self):
475
  """Test that nested object schemas are rejected."""
476
- from fastmcp.server.elicitation import validate_elicitation_json_schema
477
 
478
  with pytest.raises(
479
  TypeError, match="has type 'object' which is not a primitive type"
@@ -492,7 +517,6 @@ class TestValidation:
492
 
493
  async def test_schema_validation_rejects_arrays(self):
494
  """Test that array schemas are rejected."""
495
- from fastmcp.server.elicitation import validate_elicitation_json_schema
496
 
497
  with pytest.raises(
498
  TypeError, match="has type 'array' which is not a primitive type"
 
1
  from dataclasses import asdict, dataclass
2
  from enum import Enum
3
+ from typing import Any, Literal
4
 
5
  import pytest
6
+ from mcp.types import ElicitRequestParams
7
  from pydantic import BaseModel
8
  from typing_extensions import TypedDict
9
 
 
15
  AcceptedElicitation,
16
  CancelledElicitation,
17
  DeclinedElicitation,
18
+ validate_elicitation_json_schema,
19
  )
20
  from fastmcp.utilities.types import TypeAdapter
21
 
 
81
  assert result.data == "No name provided."
82
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  async def test_elicitation_handler_parameters():
85
  """Test that elicitation handler receives correct parameters."""
86
  mcp = FastMCP("TestServer")
 
140
 
141
 
142
  class TestScalarResponseTypes:
143
+ async def test_elicitation_no_response(self):
144
+ """Test elicitation with no response type."""
145
+ mcp = FastMCP("TestServer")
146
+
147
+ @mcp.tool
148
+ async def my_tool(context: Context) -> None:
149
+ result = await context.elicit(message="", response_type=None)
150
+ return result.data # type: ignore[attr-defined]
151
+
152
+ async def elicitation_handler(
153
+ message, response_type, params: ElicitRequestParams, ctx
154
+ ):
155
+ assert params.requestedSchema == {"type": "object", "properties": {}}
156
+ assert response_type == dict[str, Any]
157
+ return ElicitResult(action="accept")
158
+
159
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
160
+ result = await client.call_tool("my_tool", {})
161
+ assert result.data is None
162
+
163
+ async def test_elicitation_empty_response(self):
164
+ """Test elicitation with empty response type."""
165
+ mcp = FastMCP("TestServer")
166
+
167
+ @mcp.tool
168
+ async def my_tool(context: Context) -> None:
169
+ result = await context.elicit(message="", response_type=None)
170
+ return result.data # type: ignore[attr-defined]
171
+
172
+ async def elicitation_handler(
173
+ message, response_type, params: ElicitRequestParams, ctx
174
+ ):
175
+ return ElicitResult(action="accept", content={})
176
+
177
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
178
+ result = await client.call_tool("my_tool", {})
179
+ assert result.data is None
180
+
181
+ async def test_elicitation_response_when_no_response_requested(self):
182
+ """Test elicitation with no response type."""
183
+ mcp = FastMCP("TestServer")
184
+
185
+ @mcp.tool
186
+ async def my_tool(context: Context) -> None:
187
+ result = await context.elicit(message="", response_type=None)
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": "hello"})
192
+
193
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
194
+ with pytest.raises(
195
+ ToolError, match="Elicitation expected an empty response"
196
+ ):
197
+ await client.call_tool("my_tool", {})
198
+
199
  async def test_elicitation_str_response(self):
200
  """Test elicitation with string schema."""
201
  mcp = FastMCP("TestServer")
 
296
  result = await client.call_tool("my_tool", {})
297
  assert result.data == "x"
298
 
299
+ async def test_elicitation_list_of_strings_response(self):
300
  """Test elicitation with list schema."""
301
  mcp = FastMCP("TestServer")
302
 
 
493
  class TestValidation:
494
  async def test_schema_validation_rejects_non_object(self):
495
  """Test that non-object schemas are rejected."""
 
496
 
497
  with pytest.raises(TypeError, match="must be an object schema"):
498
  validate_elicitation_json_schema({"type": "string"})
499
 
 
 
 
 
 
 
 
500
  async def test_schema_validation_rejects_nested_objects(self):
501
  """Test that nested object schemas are rejected."""
 
502
 
503
  with pytest.raises(
504
  TypeError, match="has type 'object' which is not a primitive type"
 
517
 
518
  async def test_schema_validation_rejects_arrays(self):
519
  """Test that array schemas are rejected."""
 
520
 
521
  with pytest.raises(
522
  TypeError, match="has type 'array' which is not a primitive type"