Jeremiah Lowin Claude commited on
Commit
baead56
·
1 Parent(s): 03bb111

Implement elicitation feature for FastMCP following sampling pattern

Browse files

Added comprehensive elicitation support including:
- Client-side handler infrastructure following sampling pattern
- Server-side Context.elicit() method with type safety and validation
- Support for primitive types (str, int, float, bool) with automatic wrapping
- Support for dataclasses and other complex types
- Pattern matching with AcceptedElicitation, DeclinedElicitation, CancelledElicitation
- Comprehensive atomic test coverage for all scenarios

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

src/fastmcp/client/client.py CHANGED
@@ -24,6 +24,7 @@ from fastmcp.client.roots import (
24
  RootsList,
25
  create_roots_callback,
26
  )
 
27
  from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
28
  from fastmcp.exceptions import ToolError
29
  from fastmcp.server import FastMCP
@@ -52,6 +53,7 @@ __all__ = [
52
  "LogHandler",
53
  "MessageHandler",
54
  "SamplingHandler",
 
55
  "ProgressHandler",
56
  ]
57
 
@@ -141,6 +143,7 @@ class Client(Generic[ClientTransportT]):
141
  # Common args
142
  roots: RootsList | RootsHandler | None = None,
143
  sampling_handler: SamplingHandler | None = None,
 
144
  log_handler: LogHandler | None = None,
145
  message_handler: MessageHandler | None = None,
146
  progress_handler: ProgressHandler | None = None,
@@ -193,6 +196,11 @@ class Client(Generic[ClientTransportT]):
193
  sampling_handler
194
  )
195
 
 
 
 
 
 
196
  # session context management
197
  self._session: ClientSession | None = None
198
  self._exit_stack: AsyncExitStack | None = None
@@ -231,6 +239,12 @@ class Client(Generic[ClientTransportT]):
231
  sampling_callback
232
  )
233
 
 
 
 
 
 
 
234
  def is_connected(self) -> bool:
235
  """Check if the client is currently connected."""
236
  return self._session is not None
 
24
  RootsList,
25
  create_roots_callback,
26
  )
27
+ from fastmcp.client.elicitation import ElicitationHandler, create_elicitation_callback
28
  from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
29
  from fastmcp.exceptions import ToolError
30
  from fastmcp.server import FastMCP
 
53
  "LogHandler",
54
  "MessageHandler",
55
  "SamplingHandler",
56
+ "ElicitationHandler",
57
  "ProgressHandler",
58
  ]
59
 
 
143
  # Common args
144
  roots: RootsList | RootsHandler | None = None,
145
  sampling_handler: SamplingHandler | None = None,
146
+ elicitation_handler: ElicitationHandler | None = None,
147
  log_handler: LogHandler | None = None,
148
  message_handler: MessageHandler | None = None,
149
  progress_handler: ProgressHandler | None = None,
 
196
  sampling_handler
197
  )
198
 
199
+ if elicitation_handler is not None:
200
+ self._session_kwargs["elicitation_callback"] = create_elicitation_callback(
201
+ elicitation_handler
202
+ )
203
+
204
  # session context management
205
  self._session: ClientSession | None = None
206
  self._exit_stack: AsyncExitStack | None = None
 
239
  sampling_callback
240
  )
241
 
242
+ def set_elicitation_callback(self, elicitation_callback: ElicitationHandler) -> None:
243
+ """Set the elicitation callback for the client."""
244
+ self._session_kwargs["elicitation_callback"] = create_elicitation_callback(
245
+ elicitation_callback
246
+ )
247
+
248
  def is_connected(self) -> bool:
249
  """Check if the client is currently connected."""
250
  return self._session is not None
src/fastmcp/client/elicitation.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Any, TypeAlias
6
+
7
+ import mcp.types
8
+ from mcp import ClientSession
9
+ from mcp.client.session import ElicitationFnT
10
+ from mcp.shared.context import LifespanContextT, RequestContext
11
+ from mcp.types import ElicitRequestParams, ElicitResult
12
+
13
+ __all__ = ["ElicitRequestParams", "ElicitResult", "ElicitationHandler"]
14
+
15
+
16
+ ElicitationHandler: TypeAlias = Callable[
17
+ [
18
+ str, # message
19
+ dict[str, Any], # requested_schema
20
+ RequestContext[ClientSession, LifespanContextT],
21
+ ],
22
+ ElicitResult | Awaitable[ElicitResult],
23
+ ]
24
+
25
+
26
+ def create_elicitation_callback(
27
+ elicitation_handler: ElicitationHandler,
28
+ ) -> ElicitationFnT:
29
+ async def _elicitation_handler(
30
+ context: RequestContext[ClientSession, LifespanContextT],
31
+ params: ElicitRequestParams,
32
+ ) -> ElicitResult | mcp.types.ErrorData:
33
+ try:
34
+ result = elicitation_handler(
35
+ params.message, params.requestedSchema, context
36
+ )
37
+ if inspect.isawaitable(result):
38
+ result = await result
39
+
40
+ return result
41
+ except Exception as e:
42
+ return mcp.types.ErrorData(
43
+ code=mcp.types.INTERNAL_ERROR,
44
+ message=str(e),
45
+ )
46
+
47
+ return _elicitation_handler
src/fastmcp/server/context.py CHANGED
@@ -1,12 +1,13 @@
1
- from __future__ import annotations as _annotations
2
 
3
  import warnings
4
  from collections.abc import Generator
5
  from contextlib import contextmanager
6
  from contextvars import ContextVar, Token
7
  from dataclasses import dataclass
 
8
 
9
- from mcp import LoggingLevel
10
  from mcp.server.lowlevel.helper_types import ReadResourceContents
11
  from mcp.server.lowlevel.server import request_ctx
12
  from mcp.shared.context import RequestContext
@@ -23,12 +24,20 @@ from starlette.requests import Request
23
 
24
  import fastmcp.server.dependencies
25
  from fastmcp import settings
 
 
 
 
 
 
 
26
  from fastmcp.server.server import FastMCP
27
  from fastmcp.utilities.logging import get_logger
28
- from fastmcp.utilities.types import MCPContent
29
 
30
  logger = get_logger(__name__)
31
 
 
32
  _current_context: ContextVar[Context | None] = ContextVar("context", default=None)
33
 
34
 
@@ -105,6 +114,56 @@ class Context:
105
  except LookupError:
106
  raise ValueError("Context is not available outside of a request")
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  async def report_progress(
109
  self, progress: float, total: float | None = None, message: str | None = None
110
  ) -> None:
@@ -124,7 +183,7 @@ class Context:
124
  if progress_token is None:
125
  return
126
 
127
- await self.request_context.session.send_progress_notification(
128
  progress_token=progress_token,
129
  progress=progress,
130
  total=total,
@@ -160,60 +219,13 @@ class Context:
160
  """
161
  if level is None:
162
  level = "info"
163
- await self.request_context.session.send_log_message(
164
- level=level, data=message, logger=logger_name
165
- )
166
-
167
- @property
168
- def client_id(self) -> str | None:
169
- """Get the client ID if available."""
170
- return (
171
- getattr(self.request_context.meta, "client_id", None)
172
- if self.request_context.meta
173
- else None
174
  )
175
 
176
- @property
177
- def request_id(self) -> str:
178
- """Get the unique ID for this request."""
179
- return str(self.request_context.request_id)
180
-
181
- @property
182
- def session_id(self) -> str | None:
183
- """Get the MCP session ID for HTTP transports.
184
-
185
- Returns the session ID that can be used as a key for session-based
186
- data storage (e.g., Redis) to share data between tool calls within
187
- the same client session.
188
-
189
- Returns:
190
- The session ID for HTTP transports (SSE, StreamableHTTP), or None
191
- for stdio and in-memory transports which don't use session IDs.
192
-
193
- Example:
194
- ```python
195
- @server.tool
196
- def store_data(data: dict, ctx: Context) -> str:
197
- if session_id := ctx.session_id:
198
- redis_client.set(f"session:{session_id}:data", json.dumps(data))
199
- return f"Data stored for session {session_id}"
200
- return "No session ID available (stdio/memory transport)"
201
- ```
202
- """
203
- try:
204
- from fastmcp.server.dependencies import get_http_headers
205
-
206
- headers = get_http_headers(include_all=True)
207
- return headers.get("mcp-session-id")
208
- except RuntimeError:
209
- # No HTTP context available (stdio/in-memory transport)
210
- return None
211
-
212
- @property
213
- def session(self):
214
- """Access to the underlying session for advanced usage."""
215
- return self.request_context.session
216
-
217
  # Convenience methods for common log levels
218
  async def debug(self, message: str, logger_name: str | None = None) -> None:
219
  """Send a debug log message."""
@@ -233,7 +245,7 @@ class Context:
233
 
234
  async def list_roots(self) -> list[Root]:
235
  """List the roots available to the server, as indicated by the client."""
236
- result = await self.request_context.session.list_roots()
237
  return result.roots
238
 
239
  async def sample(
@@ -269,16 +281,73 @@ class Context:
269
  for m in messages
270
  ]
271
 
272
- result: CreateMessageResult = await self.request_context.session.create_message(
273
  messages=sampling_messages,
274
  system_prompt=system_prompt,
275
  temperature=temperature,
276
  max_tokens=max_tokens,
277
  model_preferences=self._parse_model_preferences(model_preferences),
 
278
  )
279
 
280
  return result.content
281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  def get_http_request(self) -> Request:
283
  """Get the active starlette request."""
284
 
 
1
+ from __future__ import annotations
2
 
3
  import warnings
4
  from collections.abc import Generator
5
  from contextlib import contextmanager
6
  from contextvars import ContextVar, Token
7
  from dataclasses import dataclass
8
+ from typing import TypeVar, cast
9
 
10
+ from mcp import LoggingLevel, ServerSession
11
  from mcp.server.lowlevel.helper_types import ReadResourceContents
12
  from mcp.server.lowlevel.server import request_ctx
13
  from mcp.shared.context import RequestContext
 
24
 
25
  import fastmcp.server.dependencies
26
  from fastmcp import settings
27
+ from fastmcp.server.elicitation import (
28
+ AcceptedElicitation,
29
+ CancelledElicitation,
30
+ DeclinedElicitation,
31
+ PrimitiveElicitationType,
32
+ get_elicitation_schema,
33
+ )
34
  from fastmcp.server.server import FastMCP
35
  from fastmcp.utilities.logging import get_logger
36
+ from fastmcp.utilities.types import MCPContent, get_cached_typeadapter
37
 
38
  logger = get_logger(__name__)
39
 
40
+ T = TypeVar("T")
41
  _current_context: ContextVar[Context | None] = ContextVar("context", default=None)
42
 
43
 
 
114
  except LookupError:
115
  raise ValueError("Context is not available outside of a request")
116
 
117
+ @property
118
+ def session(self) -> ServerSession:
119
+ """Access to the underlying session for advanced usage."""
120
+ return self.request_context.session
121
+
122
+ @property
123
+ def client_id(self) -> str | None:
124
+ """Get the client ID if available."""
125
+ return (
126
+ getattr(self.request_context.meta, "client_id", None)
127
+ if self.request_context.meta
128
+ else None
129
+ )
130
+
131
+ @property
132
+ def request_id(self) -> str:
133
+ """Get the unique ID for this request."""
134
+ return str(self.request_context.request_id)
135
+
136
+ @property
137
+ def session_id(self) -> str | None:
138
+ """Get the MCP session ID for HTTP transports.
139
+
140
+ Returns the session ID that can be used as a key for session-based
141
+ data storage (e.g., Redis) to share data between tool calls within
142
+ the same client session.
143
+
144
+ Returns:
145
+ The session ID for HTTP transports (SSE, StreamableHTTP), or None
146
+ for stdio and in-memory transports which don't use session IDs.
147
+
148
+ Example:
149
+ ```python
150
+ @server.tool
151
+ def store_data(data: dict, ctx: Context) -> str:
152
+ if session_id := ctx.session_id:
153
+ redis_client.set(f"session:{session_id}:data", json.dumps(data))
154
+ return f"Data stored for session {session_id}"
155
+ return "No session ID available (stdio/memory transport)"
156
+ ```
157
+ """
158
+ try:
159
+ from fastmcp.server.dependencies import get_http_headers
160
+
161
+ headers = get_http_headers(include_all=True)
162
+ return headers.get("mcp-session-id")
163
+ except RuntimeError:
164
+ # No HTTP context available (stdio/in-memory transport)
165
+ return None
166
+
167
  async def report_progress(
168
  self, progress: float, total: float | None = None, message: str | None = None
169
  ) -> None:
 
183
  if progress_token is None:
184
  return
185
 
186
+ await self.session.send_progress_notification(
187
  progress_token=progress_token,
188
  progress=progress,
189
  total=total,
 
219
  """
220
  if level is None:
221
  level = "info"
222
+ await self.session.send_log_message(
223
+ level=level,
224
+ data=message,
225
+ logger=logger_name,
226
+ related_request_id=self.request_id,
 
 
 
 
 
 
227
  )
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  # Convenience methods for common log levels
230
  async def debug(self, message: str, logger_name: str | None = None) -> None:
231
  """Send a debug log message."""
 
245
 
246
  async def list_roots(self) -> list[Root]:
247
  """List the roots available to the server, as indicated by the client."""
248
+ result = await self.session.list_roots()
249
  return result.roots
250
 
251
  async def sample(
 
281
  for m in messages
282
  ]
283
 
284
+ result: CreateMessageResult = await self.session.create_message(
285
  messages=sampling_messages,
286
  system_prompt=system_prompt,
287
  temperature=temperature,
288
  max_tokens=max_tokens,
289
  model_preferences=self._parse_model_preferences(model_preferences),
290
+ related_request_id=self.request_id,
291
  )
292
 
293
  return result.content
294
 
295
+ async def elicit(
296
+ self,
297
+ message: str,
298
+ response_type: type[T] | None = None,
299
+ ) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation:
300
+ """
301
+ Send an elicitation request to the client and await the response.
302
+
303
+ Call this method at any time to request additional information from
304
+ the user through the client. The client must support elicitation,
305
+ or the request will error.
306
+
307
+ Note that the MCP protocol only supports simple object schemas with
308
+ primitive types. You can provide a dataclass, TypedDict, or BaseModel to
309
+ comply. If you provide a primitive type, an object schema with a single
310
+ "value" field will be generated for the MCP interaction and
311
+ automatically deconstructed into the primitive type upon response.
312
+
313
+ Args:
314
+ message: A human-readable message explaining what information is needed
315
+ response_type: The type of the response, which should be a primitive
316
+ type or dataclass or BaseModel. If it is a primitive type, an
317
+ object schema with a single "value" field will be generated.
318
+ """
319
+ if response_type is None:
320
+ response_type = str # type: ignore
321
+
322
+ if response_type in {bool, int, float, str}:
323
+ response_type = PrimitiveElicitationType[response_type] # type: ignore
324
+
325
+ requested_schema = get_elicitation_schema(response_type) # type: ignore
326
+
327
+ result = await self.session.elicit(
328
+ message=message,
329
+ requestedSchema=requested_schema,
330
+ related_request_id=self.request_id,
331
+ )
332
+
333
+ if result.action == "accept" and result.content:
334
+ type_adapter = get_cached_typeadapter(response_type)
335
+ validated_data = cast(
336
+ T | PrimitiveElicitationType[T],
337
+ type_adapter.validate_python(result.content),
338
+ )
339
+ if isinstance(validated_data, PrimitiveElicitationType):
340
+ return AcceptedElicitation[T](data=validated_data.value)
341
+ else:
342
+ return AcceptedElicitation[T](data=validated_data)
343
+ elif result.action == "decline":
344
+ return DeclinedElicitation()
345
+ elif result.action == "cancel":
346
+ return CancelledElicitation()
347
+ else:
348
+ # This should never happen, but handle it just in case
349
+ raise ValueError(f"Unexpected elicitation action: {result.action}")
350
+
351
  def get_http_request(self) -> Request:
352
  """Get the active starlette request."""
353
 
src/fastmcp/server/elicitation.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Generic, Literal, TypeVar
5
+
6
+ from mcp.server.elicitation import (
7
+ CancelledElicitation,
8
+ DeclinedElicitation,
9
+ )
10
+ from pydantic import BaseModel
11
+
12
+ from fastmcp.utilities.json_schema import compress_schema
13
+ from fastmcp.utilities.logging import get_logger
14
+ from fastmcp.utilities.types import get_cached_typeadapter
15
+
16
+ __all__ = [
17
+ "AcceptedElicitation",
18
+ "CancelledElicitation",
19
+ "DeclinedElicitation",
20
+ "get_elicitation_schema",
21
+ "PrimitiveElicitationType",
22
+ ]
23
+
24
+ logger = get_logger(__name__)
25
+
26
+ T = TypeVar("T")
27
+
28
+
29
+ # we can't use the low-level AcceptedElicitation because it only works with BaseModels
30
+ class AcceptedElicitation(BaseModel, Generic[T]):
31
+ """Result when user accepts the elicitation."""
32
+
33
+ action: Literal["accept"] = "accept"
34
+ data: T
35
+
36
+
37
+ @dataclass
38
+ class PrimitiveElicitationType(Generic[T]):
39
+ value: T
40
+
41
+
42
+ def get_elicitation_schema(response_type: type[T]) -> dict[str, Any]:
43
+ """Get the schema for an elicitation response.
44
+
45
+ Args:
46
+ response_type: The type of the response
47
+ """
48
+
49
+ schema = get_cached_typeadapter(response_type).json_schema()
50
+ schema = compress_schema(schema)
51
+
52
+ # Validate the schema to ensure it follows MCP elicitation requirements
53
+ validate_elicitation_json_schema(schema)
54
+
55
+ return schema
56
+
57
+
58
+ def validate_elicitation_json_schema(schema: dict[str, Any]) -> None:
59
+ """Validate that a JSON schema follows MCP elicitation requirements.
60
+
61
+ This ensures the schema is compatible with MCP elicitation requirements:
62
+ - Must be an object schema
63
+ - Must only contain primitive field types (string, number, integer, boolean)
64
+ - Must be flat (no nested objects or arrays of objects)
65
+ - Only primitive types and their nullable variants are allowed
66
+
67
+ Args:
68
+ schema: The JSON schema to validate
69
+
70
+ Raises:
71
+ TypeError: If the schema doesn't meet MCP elicitation requirements
72
+ """
73
+ ALLOWED_TYPES = {"string", "number", "integer", "boolean"}
74
+
75
+ # Check that the schema is an object
76
+ if schema.get("type") != "object":
77
+ raise TypeError(
78
+ f"Elicitation schema must be an object schema, got type '{schema.get('type')}'. "
79
+ "Elicitation schemas are limited to flat objects with primitive properties only."
80
+ )
81
+
82
+ properties = schema.get("properties", {})
83
+ if not properties:
84
+ raise TypeError(
85
+ "Elicitation schema must have at least one property. "
86
+ "Empty object schemas are not allowed."
87
+ )
88
+
89
+ for prop_name, prop_schema in properties.items():
90
+ prop_type = prop_schema.get("type")
91
+
92
+ # Handle nullable types
93
+ if isinstance(prop_type, list):
94
+ if "null" in prop_type:
95
+ prop_type = [t for t in prop_type if t != "null"]
96
+ if len(prop_type) == 1:
97
+ prop_type = prop_type[0]
98
+ elif prop_schema.get("nullable", False):
99
+ continue # Nullable with no other type is fine
100
+
101
+ # Handle union types (oneOf/anyOf)
102
+ if "oneOf" in prop_schema or "anyOf" in prop_schema:
103
+ union_schemas = prop_schema.get("oneOf", []) + prop_schema.get("anyOf", [])
104
+ for union_schema in union_schemas:
105
+ union_type = union_schema.get("type")
106
+ if union_type not in ALLOWED_TYPES:
107
+ raise TypeError(
108
+ f"Elicitation schema field '{prop_name}' has union type '{union_type}' which is not "
109
+ f"a primitive type. Only {ALLOWED_TYPES} are allowed in elicitation schemas."
110
+ )
111
+ continue
112
+
113
+ # Check if it's a primitive type
114
+ if prop_type not in ALLOWED_TYPES:
115
+ raise TypeError(
116
+ f"Elicitation schema field '{prop_name}' has type '{prop_type}' which is not "
117
+ f"a primitive type. Only {ALLOWED_TYPES} are allowed in elicitation schemas."
118
+ )
119
+
120
+ # Check for nested objects or arrays of objects (not allowed)
121
+ if prop_type == "object":
122
+ raise TypeError(
123
+ f"Elicitation schema field '{prop_name}' is an object, but nested objects are not allowed. "
124
+ "Elicitation schemas must be flat objects with primitive properties only."
125
+ )
126
+
127
+ if prop_type == "array":
128
+ items_schema = prop_schema.get("items", {})
129
+ if items_schema.get("type") == "object":
130
+ raise TypeError(
131
+ f"Elicitation schema field '{prop_name}' is an array of objects, but arrays of objects are not allowed. "
132
+ "Elicitation schemas must be flat objects with primitive properties only."
133
+ )
src/fastmcp/utilities/json_schema_to_type.py ADDED
@@ -0,0 +1,492 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from collections.abc import Callable, Mapping
7
+ from copy import deepcopy
8
+ from dataclasses import MISSING, field, make_dataclass
9
+ from datetime import datetime
10
+ from enum import Enum
11
+ from typing import (
12
+ Annotated,
13
+ Any,
14
+ ForwardRef,
15
+ Literal,
16
+ Optional,
17
+ Union,
18
+ )
19
+
20
+ from pydantic import (
21
+ AnyUrl,
22
+ EmailStr,
23
+ Field,
24
+ Json,
25
+ StringConstraints,
26
+ model_validator,
27
+ )
28
+ from typing_extensions import NotRequired, TypedDict
29
+
30
+ __all__ = ["jsonschema_to_type", "JSONSchema"]
31
+
32
+
33
+ FORMAT_TYPES: dict[str, Any] = {
34
+ "date-time": datetime,
35
+ "email": EmailStr,
36
+ "uri": AnyUrl,
37
+ "json": Json,
38
+ }
39
+
40
+ _classes: dict[tuple[str, Any], type | None] = {}
41
+
42
+
43
+ def jsonschema_to_type(
44
+ schema: Mapping[str, Any],
45
+ name: str | None = None,
46
+ ) -> type:
47
+ """Convert JSON schema to appropriate Python type with validation.
48
+
49
+ Args:
50
+ schema: A JSON Schema dictionary defining the type structure and validation rules
51
+ name: Optional name for object schemas. Only allowed when schema type is "object".
52
+ If not provided for objects, name will be inferred from schema's "title"
53
+ property or default to "Root".
54
+
55
+ Returns:
56
+ A Python type (typically a dataclass for objects) with Pydantic validation
57
+
58
+ Raises:
59
+ ValueError: If a name is provided for a non-object schema
60
+
61
+ Examples:
62
+ Create a dataclass from an object schema:
63
+ ```python
64
+ schema = {
65
+ "type": "object",
66
+ "title": "Person",
67
+ "properties": {
68
+ "name": {"type": "string", "minLength": 1},
69
+ "age": {"type": "integer", "minimum": 0},
70
+ "email": {"type": "string", "format": "email"}
71
+ },
72
+ "required": ["name", "age"]
73
+ }
74
+
75
+ Person = jsonschema_to_type(schema)
76
+ # Creates a dataclass with name, age, and optional email fields:
77
+ # @dataclass
78
+ # class Person:
79
+ # name: str
80
+ # age: int
81
+ # email: str | None = None
82
+ ```
83
+ Person(name="John", age=30)
84
+
85
+ Create a scalar type with constraints:
86
+ ```python
87
+ schema = {
88
+ "type": "string",
89
+ "minLength": 3,
90
+ "pattern": "^[A-Z][a-z]+$"
91
+ }
92
+
93
+ NameType = jsonschema_to_type(schema)
94
+ # Creates Annotated[str, StringConstraints(min_length=3, pattern="^[A-Z][a-z]+$")]
95
+
96
+ @dataclass
97
+ class Name:
98
+ name: NameType
99
+ ```
100
+ """
101
+ # Always use the top-level schema for references
102
+ if schema.get("type") == "object":
103
+ return _create_dataclass(schema, name, schemas=schema)
104
+ elif name:
105
+ raise ValueError(f"Can not apply name to non-object schema: {name}")
106
+ return _schema_to_type(schema, schemas=schema)
107
+
108
+
109
+ def _hash_schema(schema: Mapping[str, Any]) -> str:
110
+ """Generate a deterministic hash for schema caching."""
111
+ return hashlib.sha256(json.dumps(schema, sort_keys=True).encode()).hexdigest()
112
+
113
+
114
+ def _resolve_ref(ref: str, schemas: Mapping[str, Any]) -> Mapping[str, Any]:
115
+ """Resolve JSON Schema reference to target schema."""
116
+ path = ref.replace("#/", "").split("/")
117
+ current = schemas
118
+ for part in path:
119
+ current = current.get(part, {})
120
+ return current
121
+
122
+
123
+ def _create_string_type(schema: Mapping[str, Any]) -> type | Annotated[Any, ...]:
124
+ """Create string type with optional constraints."""
125
+ if "const" in schema:
126
+ return Literal[schema["const"]] # type: ignore
127
+
128
+ if fmt := schema.get("format"):
129
+ if fmt == "uri":
130
+ return AnyUrl
131
+ elif fmt == "uri-reference":
132
+ return str
133
+ return FORMAT_TYPES.get(fmt, str)
134
+
135
+ constraints = {
136
+ k: v
137
+ for k, v in {
138
+ "min_length": schema.get("minLength"),
139
+ "max_length": schema.get("maxLength"),
140
+ "pattern": schema.get("pattern"),
141
+ }.items()
142
+ if v is not None
143
+ }
144
+
145
+ return Annotated[str, StringConstraints(**constraints)] if constraints else str
146
+
147
+
148
+ def _create_numeric_type(
149
+ base: type[int | float], schema: Mapping[str, Any]
150
+ ) -> type | Annotated[Any, ...]:
151
+ """Create numeric type with optional constraints."""
152
+ if "const" in schema:
153
+ return Literal[schema["const"]] # type: ignore
154
+
155
+ constraints = {
156
+ k: v
157
+ for k, v in {
158
+ "gt": schema.get("exclusiveMinimum"),
159
+ "ge": schema.get("minimum"),
160
+ "lt": schema.get("exclusiveMaximum"),
161
+ "le": schema.get("maximum"),
162
+ "multiple_of": schema.get("multipleOf"),
163
+ }.items()
164
+ if v is not None
165
+ }
166
+
167
+ return Annotated[base, Field(**constraints)] if constraints else base
168
+
169
+
170
+ def _create_enum(name: str, values: list[Any]) -> type | Enum:
171
+ """Create enum type from list of values."""
172
+ if all(isinstance(v, str) for v in values):
173
+ return Enum(name, {v.upper(): v for v in values})
174
+ return Literal[tuple(values)] # type: ignore
175
+
176
+
177
+ def _create_array_type(
178
+ schema: Mapping[str, Any], schemas: Mapping[str, Any]
179
+ ) -> type | Annotated[Any, ...]:
180
+ """Create list/set type with optional constraints."""
181
+ items = schema.get("items", {})
182
+ if isinstance(items, list):
183
+ # Handle positional item schemas
184
+ item_types = [_schema_to_type(s, schemas) for s in items]
185
+ combined = Union[tuple(item_types)]
186
+ base = list[combined]
187
+ else:
188
+ # Handle single item schema
189
+ item_type = _schema_to_type(items, schemas)
190
+ base = set if schema.get("uniqueItems") else list
191
+ base = base[item_type]
192
+
193
+ constraints = {
194
+ k: v
195
+ for k, v in {
196
+ "min_length": schema.get("minItems"),
197
+ "max_length": schema.get("maxItems"),
198
+ }.items()
199
+ if v is not None
200
+ }
201
+
202
+ return Annotated[base, Field(**constraints)] if constraints else base
203
+
204
+
205
+ def _return_Any() -> Any:
206
+ return Any
207
+
208
+
209
+ def _get_from_type_handler(
210
+ schema: Mapping[str, Any], schemas: Mapping[str, Any]
211
+ ) -> Callable[..., Any]:
212
+ """Get the appropriate type handler for the schema."""
213
+
214
+ type_handlers: dict[str, Callable[..., Any]] = { # TODO
215
+ "string": lambda s: _create_string_type(s), # type: ignore
216
+ "integer": lambda s: _create_numeric_type(int, s), # type: ignore
217
+ "number": lambda s: _create_numeric_type(float, s), # type: ignore
218
+ "boolean": lambda _: bool, # type: ignore
219
+ "null": lambda _: type(None), # type: ignore
220
+ "array": lambda s: _create_array_type(s, schemas), # type: ignore
221
+ "object": lambda s: _create_dataclass(s, s.get("title"), schemas), # type: ignore
222
+ }
223
+ return type_handlers.get(schema.get("type", None), _return_Any)
224
+
225
+
226
+ def _schema_to_type(
227
+ schema: Mapping[str, Any],
228
+ schemas: Mapping[str, Any],
229
+ ) -> type:
230
+ """Convert schema to appropriate Python type."""
231
+ if not schema:
232
+ return object
233
+
234
+ if "type" not in schema and "properties" in schema:
235
+ return _create_dataclass(schema, schema.get("title", "<unknown>"), schemas)
236
+
237
+ # Handle references first
238
+ if "$ref" in schema:
239
+ ref = schema["$ref"]
240
+ # Handle self-reference
241
+ if ref == "#":
242
+ return ForwardRef(schema.get("title", "Root"))
243
+ return _schema_to_type(_resolve_ref(ref, schemas), schemas)
244
+
245
+ if "const" in schema:
246
+ return Literal[schema["const"]] # type: ignore
247
+
248
+ if "enum" in schema:
249
+ return _create_enum(f"Enum_{len(_classes)}", schema["enum"])
250
+
251
+ schema_type = schema.get("type")
252
+ if not schema_type:
253
+ return Any
254
+
255
+ if isinstance(schema_type, list):
256
+ # Create a copy of the schema for each type, but keep all constraints
257
+ types: list[type | Any] = []
258
+ for t in schema_type:
259
+ type_schema = schema.copy()
260
+ type_schema["type"] = t
261
+ types.append(_schema_to_type(type_schema, schemas))
262
+ has_null = type(None) in types
263
+ types = [t for t in types if t is not type(None)]
264
+ if has_null:
265
+ return Optional[tuple(types) if len(types) > 1 else types[0]]
266
+ return Union[tuple(types)]
267
+
268
+ return _get_from_type_handler(schema, schemas)(schema)
269
+
270
+
271
+ def _sanitize_name(name: str) -> str:
272
+ """Convert string to valid Python identifier."""
273
+ # Step 1: replace everything except [0-9a-zA-Z_] with underscores
274
+ cleaned = re.sub(r"[^0-9a-zA-Z_]", "_", name)
275
+ # Step 2: deduplicate underscores
276
+ cleaned = re.sub(r"__+", "_", cleaned)
277
+ # Step 3: if the first char of original name isn't a letter, prepend field_
278
+ if not name or not re.match(r"[a-zA-Z]", name[0]):
279
+ cleaned = f"field_{cleaned}"
280
+ # Step 4: deduplicate again and strip trailing underscores
281
+ cleaned = re.sub(r"__+", "_", cleaned).strip("_")
282
+ return cleaned
283
+
284
+
285
+ def _get_default_value(
286
+ schema: dict[str, Any],
287
+ prop_name: str,
288
+ parent_default: dict[str, Any] | None = None,
289
+ ) -> Any:
290
+ """Get default value with proper priority ordering.
291
+ 1. Value from parent's default if it exists
292
+ 2. Property's own default if it exists
293
+ 3. None
294
+ """
295
+ if parent_default is not None and prop_name in parent_default:
296
+ return parent_default[prop_name]
297
+ return schema.get("default")
298
+
299
+
300
+ def _create_field_with_default(
301
+ field_type: type,
302
+ default_value: Any,
303
+ schema: dict[str, Any],
304
+ ) -> Any:
305
+ """Create a field with simplified default handling."""
306
+ # Always use None as default for complex types
307
+ if isinstance(default_value, (dict, list)) or default_value is None:
308
+ return field(default=None)
309
+
310
+ # For simple types, use the value directly
311
+ return field(default=default_value)
312
+
313
+
314
+ def _create_dataclass(
315
+ schema: Mapping[str, Any],
316
+ name: str | None = None,
317
+ schemas: Mapping[str, Any] | None = None,
318
+ ) -> type:
319
+ """Create dataclass from object schema."""
320
+ name = name or schema.get("title", "Root")
321
+ # Sanitize name for class creation
322
+ sanitized_name = _sanitize_name(name)
323
+ schema_hash = _hash_schema(schema)
324
+ cache_key = (schema_hash, sanitized_name)
325
+ original_schema = dict(schema) # Store copy for validator
326
+
327
+ # Return existing class if already built
328
+ if cache_key in _classes:
329
+ existing = _classes[cache_key]
330
+ if existing is None:
331
+ return ForwardRef(sanitized_name)
332
+ return existing
333
+
334
+ # Place placeholder for recursive references
335
+ _classes[cache_key] = None
336
+
337
+ if "$ref" in schema:
338
+ ref = schema["$ref"]
339
+ if ref == "#":
340
+ return ForwardRef(sanitized_name)
341
+ schema = _resolve_ref(ref, schemas or {})
342
+
343
+ properties = schema.get("properties", {})
344
+ required = schema.get("required", [])
345
+
346
+ fields: list[tuple[Any, ...]] = []
347
+ for prop_name, prop_schema in properties.items():
348
+ field_name = _sanitize_name(prop_name)
349
+
350
+ # Check for self-reference in property
351
+ if prop_schema.get("$ref") == "#":
352
+ field_type = ForwardRef(sanitized_name)
353
+ else:
354
+ field_type = _schema_to_type(prop_schema, schemas)
355
+
356
+ default_val = prop_schema.get("default", MISSING)
357
+ is_required = prop_name in required
358
+
359
+ # Include alias in field metadata
360
+ meta = {"alias": prop_name}
361
+
362
+ if default_val is not MISSING:
363
+ if isinstance(default_val, (dict, list)):
364
+ field_def = field(
365
+ default_factory=lambda d=default_val: deepcopy(d), metadata=meta
366
+ )
367
+ else:
368
+ field_def = field(default=default_val, metadata=meta)
369
+ else:
370
+ if is_required:
371
+ field_def = field(metadata=meta)
372
+ else:
373
+ field_def = field(default=None, metadata=meta)
374
+
375
+ if is_required and default_val is not MISSING:
376
+ fields.append((field_name, field_type, field_def))
377
+ elif is_required:
378
+ fields.append((field_name, field_type, field_def))
379
+ else:
380
+ fields.append((field_name, Optional[field_type], field_def))
381
+
382
+ cls = make_dataclass(sanitized_name, fields, kw_only=True)
383
+
384
+ # Add model validator for defaults
385
+ @model_validator(mode="before")
386
+ @classmethod
387
+ def _apply_defaults(cls, data: Mapping[str, Any]):
388
+ if isinstance(data, dict):
389
+ return _merge_defaults(data, original_schema)
390
+ return data
391
+
392
+ setattr(cls, "_apply_defaults", _apply_defaults)
393
+
394
+ # Store completed class
395
+ _classes[cache_key] = cls
396
+ return cls
397
+
398
+
399
+ def _merge_defaults(
400
+ data: Mapping[str, Any],
401
+ schema: Mapping[str, Any],
402
+ parent_default: Mapping[str, Any] | None = None,
403
+ ) -> dict[str, Any]:
404
+ """Merge defaults with provided data at all levels."""
405
+ # If we have no data
406
+ if not data:
407
+ # Start with parent default if available
408
+ if parent_default:
409
+ result = dict(parent_default)
410
+ # Otherwise use schema default if available
411
+ elif "default" in schema:
412
+ result = dict(schema["default"])
413
+ # Otherwise start empty
414
+ else:
415
+ result = {}
416
+ # If we have data and a parent default, merge them
417
+ elif parent_default:
418
+ result = dict(parent_default)
419
+ for key, value in data.items():
420
+ if (
421
+ isinstance(value, dict)
422
+ and key in result
423
+ and isinstance(result[key], dict)
424
+ ):
425
+ # recursively merge nested dicts
426
+ result[key] = _merge_defaults(value, {"properties": {}}, result[key])
427
+ else:
428
+ result[key] = value
429
+ # Otherwise just use the data
430
+ else:
431
+ result = dict(data)
432
+
433
+ # For each property in the schema
434
+ for prop_name, prop_schema in schema.get("properties", {}).items():
435
+ # If property is missing, apply defaults in priority order
436
+ if prop_name not in result:
437
+ if parent_default and prop_name in parent_default:
438
+ result[prop_name] = parent_default[prop_name]
439
+ elif "default" in prop_schema:
440
+ result[prop_name] = prop_schema["default"]
441
+
442
+ # If property exists and is an object, recursively merge
443
+ if (
444
+ prop_name in result
445
+ and isinstance(result[prop_name], dict)
446
+ and prop_schema.get("type") == "object"
447
+ ):
448
+ # Get the appropriate default for this nested object
449
+ nested_default = None
450
+ if parent_default and prop_name in parent_default:
451
+ nested_default = parent_default[prop_name]
452
+ elif "default" in prop_schema:
453
+ nested_default = prop_schema["default"]
454
+
455
+ result[prop_name] = _merge_defaults(
456
+ result[prop_name], prop_schema, nested_default
457
+ )
458
+
459
+ return result
460
+
461
+
462
+ class JSONSchema(TypedDict):
463
+ type: NotRequired[str | list[str]]
464
+ properties: NotRequired[dict[str, JSONSchema]]
465
+ required: NotRequired[list[str]]
466
+ additionalProperties: NotRequired[bool | JSONSchema]
467
+ items: NotRequired[JSONSchema | list[JSONSchema]]
468
+ enum: NotRequired[list[Any]]
469
+ const: NotRequired[Any]
470
+ default: NotRequired[Any]
471
+ description: NotRequired[str]
472
+ title: NotRequired[str]
473
+ examples: NotRequired[list[Any]]
474
+ format: NotRequired[str]
475
+ allOf: NotRequired[list[JSONSchema]]
476
+ anyOf: NotRequired[list[JSONSchema]]
477
+ oneOf: NotRequired[list[JSONSchema]]
478
+ not_: NotRequired[JSONSchema]
479
+ definitions: NotRequired[dict[str, JSONSchema]]
480
+ dependencies: NotRequired[dict[str, JSONSchema | list[str]]]
481
+ pattern: NotRequired[str]
482
+ minLength: NotRequired[int]
483
+ maxLength: NotRequired[int]
484
+ minimum: NotRequired[int | float]
485
+ maximum: NotRequired[int | float]
486
+ exclusiveMinimum: NotRequired[int | float]
487
+ exclusiveMaximum: NotRequired[int | float]
488
+ multipleOf: NotRequired[int | float]
489
+ uniqueItems: NotRequired[bool]
490
+ minItems: NotRequired[int]
491
+ maxItems: NotRequired[int]
492
+ additionalItems: NotRequired[bool | JSONSchema]
src/fastmcp/utilities/types.py CHANGED
@@ -42,7 +42,7 @@ def get_cached_typeadapter(cls: T) -> TypeAdapter[T]:
42
  The _parent_depth is set to 3 to look at an additional frame, since this
43
  function is in its own scope.
44
  """
45
- return TypeAdapter(cls, _parent_depth=3)
46
 
47
 
48
  def issubclass_safe(cls: type, base: type) -> bool:
 
42
  The _parent_depth is set to 3 to look at an additional frame, since this
43
  function is in its own scope.
44
  """
45
+ return TypeAdapter(cls)
46
 
47
 
48
  def issubclass_safe(cls: type, base: type) -> bool:
tests/client/test_elicitation.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+
3
+ import pytest
4
+ from mcp.types import ElicitResult
5
+
6
+ from fastmcp import Context, FastMCP
7
+ from fastmcp.client.client import Client
8
+ from fastmcp.server.elicitation import (
9
+ AcceptedElicitation,
10
+ CancelledElicitation,
11
+ DeclinedElicitation,
12
+ )
13
+
14
+
15
+ @pytest.fixture
16
+ def fastmcp_server():
17
+ mcp = FastMCP("TestServer")
18
+
19
+ @dataclass
20
+ class Person:
21
+ name: str
22
+
23
+ @mcp.tool
24
+ async def ask_for_name(context: Context) -> str:
25
+ result = await context.elicit(
26
+ message="What is your name?",
27
+ response_type=Person,
28
+ )
29
+ if result.action == "accept":
30
+ return f"Hello, {result.data.name}!"
31
+ else:
32
+ return "No name provided."
33
+
34
+ @mcp.tool
35
+ def simple_test() -> str:
36
+ return "Hello!"
37
+
38
+ return mcp
39
+
40
+
41
+ async def test_elicitation_accept_content(fastmcp_server):
42
+ """Test basic elicitation functionality."""
43
+
44
+ async def elicitation_handler(message, schema, ctx):
45
+ # Mock user providing their name
46
+ return ElicitResult(action="accept", content={"name": "Alice"})
47
+
48
+ async with Client(
49
+ fastmcp_server, elicitation_handler=elicitation_handler
50
+ ) as client:
51
+ result = await client.call_tool("ask_for_name", {})
52
+ assert result[0].text == "Hello, Alice!" # type: ignore[attr-defined]
53
+
54
+
55
+ async def test_elicitation_decline(fastmcp_server):
56
+ """Test that elicitation handler receives correct parameters."""
57
+
58
+ async def elicitation_handler(message, schema, ctx):
59
+ return ElicitResult(action="decline")
60
+
61
+ async with Client(
62
+ fastmcp_server, elicitation_handler=elicitation_handler
63
+ ) as client:
64
+ result = await client.call_tool("ask_for_name", {})
65
+ assert result[0].text == "No name provided." # type: ignore[attr-defined]
66
+
67
+
68
+ async def test_default_response_type(fastmcp_server):
69
+ """Test elicitation with string content."""
70
+ mcp = FastMCP("TestServer")
71
+
72
+ @mcp.tool
73
+ async def ask_for_color(context: Context) -> str:
74
+ result = await context.elicit(
75
+ message="What is your favorite color?"
76
+ # Default schema should be string
77
+ )
78
+ if result.action == "accept":
79
+ assert isinstance(result.data, str)
80
+ return f"Your favorite color is {result.data}!"
81
+ return "No color provided"
82
+
83
+ async def elicitation_handler(message, schema, ctx):
84
+ # Mock user providing their favorite color as string in content dict
85
+ return ElicitResult(action="accept", content={"value": "blue"})
86
+
87
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
88
+ result = await client.call_tool("ask_for_color", {})
89
+ assert result[0].text == "Your favorite color is blue!" # type: ignore[attr-defined]
90
+
91
+
92
+ async def test_elicitation_handler_parameters():
93
+ """Test that elicitation handler receives correct parameters."""
94
+ mcp = FastMCP("TestServer")
95
+ captured_params = {}
96
+
97
+ @mcp.tool
98
+ async def test_tool(context: Context) -> str:
99
+ await context.elicit(
100
+ message="Test message",
101
+ response_type=int,
102
+ )
103
+ return "done"
104
+
105
+ async def elicitation_handler(message, schema, ctx):
106
+ captured_params["message"] = message
107
+ captured_params["schema"] = schema
108
+ captured_params["ctx"] = ctx
109
+ return ElicitResult(action="accept", content={"value": 42})
110
+
111
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
112
+ await client.call_tool("test_tool", {})
113
+
114
+ assert captured_params["message"] == "Test message"
115
+ assert captured_params["schema"] == {
116
+ "properties": {"value": {"title": "Value", "type": "integer"}},
117
+ "required": ["value"],
118
+ "title": "PrimitiveElicitationType",
119
+ "type": "object",
120
+ }
121
+ assert captured_params["ctx"] is not None
122
+
123
+
124
+ async def test_elicitation_default_string_schema():
125
+ """Test elicitation with default string schema."""
126
+ mcp = FastMCP("TestServer")
127
+
128
+ @mcp.tool
129
+ async def ask_for_input(context: Context) -> str:
130
+ result = await context.elicit(
131
+ message="Please provide some input"
132
+ # No schema provided - should default to string
133
+ )
134
+ if result.action == "accept":
135
+ return f"You said: {result.data}"
136
+ return "No input provided"
137
+
138
+ async def elicitation_handler(message, schema, ctx):
139
+ # Verify default schema is wrapped string object
140
+ expected_schema = {
141
+ "properties": {"value": {"title": "Value", "type": "string"}},
142
+ "required": ["value"],
143
+ "title": "PrimitiveElicitationType",
144
+ "type": "object",
145
+ }
146
+ assert schema == expected_schema
147
+ return ElicitResult(action="accept", content={"value": "Hello world!"})
148
+
149
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
150
+ result = await client.call_tool("ask_for_input", {})
151
+ assert result[0].text == "You said: Hello world!" # type: ignore[attr-defined]
152
+
153
+
154
+ async def test_elicitation_cancel_action():
155
+ """Test user canceling elicitation request."""
156
+ mcp = FastMCP("TestServer")
157
+
158
+ @mcp.tool
159
+ async def ask_for_optional_info(context: Context) -> str:
160
+ result = await context.elicit(
161
+ message="Optional: What's your age?", response_type=int
162
+ )
163
+ if result.action == "cancel":
164
+ return "Request was canceled"
165
+ elif result.action == "accept":
166
+ return f"Age: {result.data}"
167
+ else:
168
+ return "No response provided"
169
+
170
+ async def elicitation_handler(message, schema, ctx):
171
+ return ElicitResult(action="cancel")
172
+
173
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
174
+ result = await client.call_tool("ask_for_optional_info", {})
175
+ assert result[0].text == "Request was canceled" # type: ignore[attr-defined]
176
+
177
+
178
+ async def test_elicitation_number_schema():
179
+ """Test elicitation with number schema."""
180
+ mcp = FastMCP("TestServer")
181
+
182
+ @mcp.tool
183
+ async def get_age(context: Context) -> str:
184
+ result = await context.elicit(message="How old are you?", response_type=int)
185
+ if result.action == "accept":
186
+ return f"You are {result.data} years old"
187
+ return "No age provided"
188
+
189
+ async def elicitation_handler(message, schema, ctx):
190
+ expected_schema = {
191
+ "properties": {"value": {"title": "Value", "type": "integer"}},
192
+ "required": ["value"],
193
+ "title": "PrimitiveElicitationType",
194
+ "type": "object",
195
+ }
196
+ assert schema == expected_schema
197
+ return ElicitResult(action="accept", content={"value": 25})
198
+
199
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
200
+ result = await client.call_tool("get_age", {})
201
+ assert result[0].text == "You are 25 years old" # type: ignore[attr-defined]
202
+
203
+
204
+ async def test_elicitation_handler_error():
205
+ """Test error handling in elicitation handler."""
206
+ mcp = FastMCP("TestServer")
207
+
208
+ @mcp.tool
209
+ async def failing_elicit(context: Context) -> str:
210
+ try:
211
+ result = await context.elicit(message="This will fail", response_type=str)
212
+ assert result.action == "accept"
213
+ return f"Got: {result.data}"
214
+ except Exception as e:
215
+ return f"Error: {str(e)}"
216
+
217
+ async def elicitation_handler(message, schema, ctx):
218
+ raise ValueError("Handler failed!")
219
+
220
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
221
+ result = await client.call_tool("failing_elicit", {})
222
+ assert "Error:" in result[0].text # type: ignore[attr-defined]
223
+
224
+
225
+ async def test_elicitation_multiple_calls():
226
+ """Test multiple elicitation calls in sequence."""
227
+ mcp = FastMCP("TestServer")
228
+
229
+ @mcp.tool
230
+ async def multi_step_form(context: Context) -> str:
231
+ # First question
232
+ name_result = await context.elicit(
233
+ message="What's your name?", response_type=str
234
+ )
235
+ if name_result.action != "accept":
236
+ return "Form abandoned"
237
+
238
+ # Second question
239
+ age_result = await context.elicit(message="What's your age?", response_type=int)
240
+ if age_result.action != "accept":
241
+ return f"Hello {name_result.data}, form incomplete"
242
+
243
+ return f"Hello {name_result.data}, you are {age_result.data} years old"
244
+
245
+ call_count = 0
246
+
247
+ async def elicitation_handler(message, schema, ctx):
248
+ nonlocal call_count
249
+ call_count += 1
250
+ if call_count == 1:
251
+ assert "name" in message.lower()
252
+ expected_schema = {
253
+ "properties": {"value": {"title": "Value", "type": "string"}},
254
+ "required": ["value"],
255
+ "title": "PrimitiveElicitationType",
256
+ "type": "object",
257
+ }
258
+ assert schema == expected_schema
259
+ return ElicitResult(action="accept", content={"value": "Bob"})
260
+ elif call_count == 2:
261
+ assert "age" in message.lower()
262
+ expected_schema = {
263
+ "properties": {"value": {"title": "Value", "type": "integer"}},
264
+ "required": ["value"],
265
+ "title": "PrimitiveElicitationType",
266
+ "type": "object",
267
+ }
268
+ assert schema == expected_schema
269
+ return ElicitResult(action="accept", content={"value": 25})
270
+ else:
271
+ raise ValueError("Unexpected call")
272
+
273
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
274
+ result = await client.call_tool("multi_step_form", {})
275
+ assert result[0].text == "Hello Bob, you are 25 years old" # type: ignore[attr-defined]
276
+ assert call_count == 2
277
+
278
+
279
+ async def test_dataclass_response_type():
280
+ """Test elicitation with dataclass response type."""
281
+ mcp = FastMCP("TestServer")
282
+
283
+ @dataclass
284
+ class UserInfo:
285
+ name: str
286
+ age: int
287
+
288
+ @mcp.tool
289
+ async def get_user_info(context: Context) -> str:
290
+ result = await context.elicit(
291
+ message="Please provide your information", response_type=UserInfo
292
+ )
293
+ if result.action == "accept":
294
+ user = result.data
295
+ return f"User: {user.name}, age: {user.age}"
296
+ return "No user info provided"
297
+
298
+ async def elicitation_handler(message, schema, ctx):
299
+ # Verify the schema has the dataclass fields
300
+ assert schema["type"] == "object"
301
+ assert "name" in schema["properties"]
302
+ assert "age" in schema["properties"]
303
+ assert schema["properties"]["name"]["type"] == "string"
304
+ assert schema["properties"]["age"]["type"] == "integer"
305
+
306
+ return ElicitResult(action="accept", content={"name": "Alice", "age": 30})
307
+
308
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
309
+ result = await client.call_tool("get_user_info", {})
310
+ assert result[0].text == "User: Alice, age: 30" # type: ignore[attr-defined]
311
+
312
+
313
+ async def test_primitive_type_string():
314
+ """Test elicitation with string primitive type."""
315
+ mcp = FastMCP("TestServer")
316
+
317
+ @mcp.tool
318
+ async def test_string(context: Context) -> str:
319
+ result = await context.elicit("Enter text:", response_type=str)
320
+ assert result.action == "accept"
321
+ return f"Got: {result.data}"
322
+
323
+ async def elicitation_handler(message, schema, ctx):
324
+ assert schema["properties"]["value"]["type"] == "string"
325
+ return ElicitResult(action="accept", content={"value": "hello"})
326
+
327
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
328
+ result = await client.call_tool("test_string", {})
329
+ assert result[0].text == "Got: hello" # type: ignore[attr-defined]
330
+
331
+
332
+ async def test_primitive_type_int():
333
+ """Test elicitation with integer primitive type."""
334
+ mcp = FastMCP("TestServer")
335
+
336
+ @mcp.tool
337
+ async def test_int(context: Context) -> str:
338
+ result = await context.elicit("Enter number:", response_type=int)
339
+ assert result.action == "accept"
340
+ return f"Got: {result.data}"
341
+
342
+ async def elicitation_handler(message, schema, ctx):
343
+ assert schema["properties"]["value"]["type"] == "integer"
344
+ return ElicitResult(action="accept", content={"value": 42})
345
+
346
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
347
+ result = await client.call_tool("test_int", {})
348
+ assert result[0].text == "Got: 42" # type: ignore[attr-defined]
349
+
350
+
351
+ async def test_primitive_type_float():
352
+ """Test elicitation with float primitive type."""
353
+ mcp = FastMCP("TestServer")
354
+
355
+ @mcp.tool
356
+ async def test_float(context: Context) -> str:
357
+ result = await context.elicit("Enter decimal:", response_type=float)
358
+ assert result.action == "accept"
359
+ return f"Got: {result.data}"
360
+
361
+ async def elicitation_handler(message, schema, ctx):
362
+ assert schema["properties"]["value"]["type"] == "number"
363
+ return ElicitResult(action="accept", content={"value": 3.14})
364
+
365
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
366
+ result = await client.call_tool("test_float", {})
367
+ assert result[0].text == "Got: 3.14" # type: ignore[attr-defined]
368
+
369
+
370
+ async def test_primitive_type_bool():
371
+ """Test elicitation with boolean primitive type."""
372
+ mcp = FastMCP("TestServer")
373
+
374
+ @mcp.tool
375
+ async def test_bool(context: Context) -> str:
376
+ result = await context.elicit("Enter true/false:", response_type=bool)
377
+ assert result.action == "accept"
378
+ return f"Got: {result.data}"
379
+
380
+ async def elicitation_handler(message, schema, ctx):
381
+ assert schema["properties"]["value"]["type"] == "boolean"
382
+ return ElicitResult(action="accept", content={"value": True})
383
+
384
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
385
+ result = await client.call_tool("test_bool", {})
386
+ assert result[0].text == "Got: True" # type: ignore[attr-defined]
387
+
388
+
389
+ async def test_schema_validation_rejects_non_object():
390
+ """Test that non-object schemas are rejected."""
391
+ from fastmcp.server.elicitation import validate_elicitation_json_schema
392
+
393
+ with pytest.raises(TypeError, match="must be an object schema"):
394
+ validate_elicitation_json_schema({"type": "string"})
395
+
396
+
397
+ async def test_schema_validation_rejects_empty_object():
398
+ """Test that object schemas without properties are rejected."""
399
+ from fastmcp.server.elicitation import validate_elicitation_json_schema
400
+
401
+ with pytest.raises(TypeError, match="must have at least one property"):
402
+ validate_elicitation_json_schema({"type": "object"})
403
+
404
+
405
+ async def test_schema_validation_rejects_nested_objects():
406
+ """Test that nested object schemas are rejected."""
407
+ from fastmcp.server.elicitation import validate_elicitation_json_schema
408
+
409
+ with pytest.raises(
410
+ TypeError, match="has type 'object' which is not a primitive type"
411
+ ):
412
+ validate_elicitation_json_schema(
413
+ {
414
+ "type": "object",
415
+ "properties": {
416
+ "user": {
417
+ "type": "object",
418
+ "properties": {"name": {"type": "string"}},
419
+ }
420
+ },
421
+ }
422
+ )
423
+
424
+
425
+ async def test_schema_validation_rejects_arrays():
426
+ """Test that array schemas are rejected."""
427
+ from fastmcp.server.elicitation import validate_elicitation_json_schema
428
+
429
+ with pytest.raises(
430
+ TypeError, match="has type 'array' which is not a primitive type"
431
+ ):
432
+ validate_elicitation_json_schema(
433
+ {
434
+ "type": "object",
435
+ "properties": {"users": {"type": "array", "items": {"type": "string"}}},
436
+ }
437
+ )
438
+
439
+
440
+ async def test_pattern_matching_accept():
441
+ """Test pattern matching with AcceptedElicitation."""
442
+ mcp = FastMCP("TestServer")
443
+
444
+ @mcp.tool
445
+ async def pattern_match_tool(context: Context) -> str:
446
+ result = await context.elicit("Enter your name:", response_type=str)
447
+
448
+ match result:
449
+ case AcceptedElicitation(data=name):
450
+ return f"Hello {name}!"
451
+ case DeclinedElicitation():
452
+ return "You declined"
453
+ case CancelledElicitation():
454
+ return "Cancelled"
455
+
456
+ async def elicitation_handler(message, schema, ctx):
457
+ return ElicitResult(action="accept", content={"value": "Alice"})
458
+
459
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
460
+ result = await client.call_tool("pattern_match_tool", {})
461
+ assert result[0].text == "Hello Alice!" # type: ignore[attr-defined]
462
+
463
+
464
+ async def test_pattern_matching_decline():
465
+ """Test pattern matching with DeclinedElicitation."""
466
+ mcp = FastMCP("TestServer")
467
+
468
+ @mcp.tool
469
+ async def pattern_match_tool(context: Context) -> str:
470
+ result = await context.elicit("Enter your name:", response_type=str)
471
+
472
+ match result:
473
+ case AcceptedElicitation(data=name):
474
+ return f"Hello {name}!"
475
+ case DeclinedElicitation():
476
+ return "You declined"
477
+ case CancelledElicitation():
478
+ return "Cancelled"
479
+
480
+ async def elicitation_handler(message, schema, ctx):
481
+ return ElicitResult(action="decline")
482
+
483
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
484
+ result = await client.call_tool("pattern_match_tool", {})
485
+ assert result[0].text == "You declined" # type: ignore[attr-defined]
486
+
487
+
488
+ async def test_pattern_matching_cancel():
489
+ """Test pattern matching with CancelledElicitation."""
490
+ mcp = FastMCP("TestServer")
491
+
492
+ @mcp.tool
493
+ async def pattern_match_tool(context: Context) -> str:
494
+ result = await context.elicit("Enter your name:", response_type=str)
495
+
496
+ match result:
497
+ case AcceptedElicitation(data=name):
498
+ return f"Hello {name}!"
499
+ case DeclinedElicitation():
500
+ return "You declined"
501
+ case CancelledElicitation():
502
+ return "Cancelled"
503
+
504
+ async def elicitation_handler(message, schema, ctx):
505
+ return ElicitResult(action="cancel")
506
+
507
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
508
+ result = await client.call_tool("pattern_match_tool", {})
509
+ assert result[0].text == "Cancelled" # type: ignore[attr-defined]