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

Provide a response type to client

Browse files
CLAUDE.md CHANGED
@@ -1,5 +1,10 @@
1
  # FastMCP Development Guidelines
2
 
 
 
 
 
 
3
  ## Testing and Investigation
4
 
5
  ### In-Memory Transport - Always Preferred
@@ -34,4 +39,4 @@ async with Client(transport=StreamableHttpTransport(server_url)) as client:
34
  - You must always run pre-commit if you open a PR, because it is run as part of a required check.
35
  - When opening PRs, apply labels appropriately for bugs/breaking changes/enhancements/features. Generally, improvements are enhancements (not features) unless told otherwise.
36
  - NEVER modify files in docs/python-sdk/**, as they are auto-generated.
37
- - Use # type: ignore[attr-defined] in unit tests when accessing an MCP result of indeterminate type instead of asserting its type
 
1
  # FastMCP Development Guidelines
2
 
3
+ ## Documentation
4
+
5
+ - Documentation uses the Mintlify framework
6
+ - Files must be present in docs.json to be included in the documentation
7
+
8
  ## Testing and Investigation
9
 
10
  ### In-Memory Transport - Always Preferred
 
39
  - You must always run pre-commit if you open a PR, because it is run as part of a required check.
40
  - When opening PRs, apply labels appropriately for bugs/breaking changes/enhancements/features. Generally, improvements are enhancements (not features) unless told otherwise.
41
  - NEVER modify files in docs/python-sdk/**, as they are auto-generated.
42
+ - Use # type: ignore[attr-defined] in unit tests when accessing an MCP result of indeterminate type instead of asserting its type
docs/clients/elicitation.mdx ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: User Elicitation
3
+ sidebarTitle: Elicitation
4
+ description: Handle server-initiated user input requests with structured schemas.
5
+ icon: user-check
6
+ ---
7
+
8
+ import { VersionBadge } from "/snippets/version-badge.mdx";
9
+
10
+ <VersionBadge version="2.10.0" />
11
+
12
+ ## What is Elicitation?
13
+
14
+ Elicitation allows MCP servers to request structured input from users during tool execution. Instead of requiring all inputs upfront, servers can interactively ask users for information as needed - like prompting for missing parameters, requesting clarification, or gathering additional context.
15
+
16
+ For example, a file management tool might ask "Which directory should I create?" or a data analysis tool might request "What date range should I analyze?"
17
+
18
+ ## How FastMCP Makes Elicitation Easy
19
+
20
+ FastMCP's client provides a helpful abstraction layer that:
21
+
22
+ - **Converts JSON schemas to Python types**: The raw MCP protocol uses JSON schemas, but FastMCP automatically converts these to Python dataclasses
23
+ - **Provides structured constructors**: Instead of manually building dictionaries that match the schema, you get dataclass constructors that ensure correct structure
24
+ - **Handles type conversion**: FastMCP takes care of converting between JSON representations and Python objects
25
+ - **Runtime introspection**: You can inspect the generated dataclass fields to understand the expected structure
26
+
27
+ When you implement an elicitation handler, FastMCP gives you a dataclass type that matches the server's schema, making it easy to create properly structured responses without having to manually parse JSON schemas.
28
+
29
+ ## Elicitation Handler
30
+
31
+ Provide an `elicitation_handler` function when creating the client. FastMCP automatically converts the server's JSON schema into a Python dataclass type, making it easy to construct the response:
32
+
33
+ ```python
34
+ from fastmcp import Client
35
+ from fastmcp.client.elicitation import ElicitResult
36
+
37
+ async def elicitation_handler(message: str, response_type: type, params, context) -> ElicitResult:
38
+ # Present the message to the user and collect input
39
+ user_input = input(f"{message}: ")
40
+
41
+ # Create response using the provided dataclass type
42
+ # FastMCP converted the JSON schema to this Python type for you
43
+ response_data = response_type(value=user_input)
44
+
45
+ return ElicitResult(action="accept", content=response_data)
46
+
47
+ client = Client(
48
+ "my_mcp_server.py",
49
+ elicitation_handler=elicitation_handler,
50
+ )
51
+ ```
52
+
53
+ ### Handler Parameters
54
+
55
+ The elicitation handler receives four parameters:
56
+
57
+ <Card icon="code" title="Elicitation Handler Parameters">
58
+ <ResponseField name="message" type="str">
59
+ The prompt message to display to the user
60
+ </ResponseField>
61
+
62
+ <ResponseField name="response_type" type="type">
63
+ 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.
64
+ </ResponseField>
65
+
66
+ <ResponseField name="params" type="ElicitRequestParams">
67
+ The original MCP elicitation request parameters, including the raw JSON schema in `params.requestedSchema` if you need it
68
+ </ResponseField>
69
+
70
+ <ResponseField name="context" type="RequestContext">
71
+ Request context containing metadata about the elicitation request
72
+ </ResponseField>
73
+ </Card>
74
+
75
+ ### Response Actions
76
+
77
+ The handler must return an `ElicitResult` object that includes both an action and (when accepted) the user's input:
78
+
79
+ <Card icon="code" title="ElicitResult Structure">
80
+ <ResponseField name="action" type="Literal['accept', 'decline', 'cancel']">
81
+ How the user responded to the elicitation request
82
+ </ResponseField>
83
+
84
+ <ResponseField name="content" type="dataclass instance | dict | None">
85
+ The user's input data (required for "accept", omitted for "decline"/"cancel")
86
+ </ResponseField>
87
+ </Card>
88
+
89
+ **Action Types:**
90
+ - **`accept`**: User provided valid input - include their data in the `content` field
91
+ - **`decline`**: User chose not to provide the requested information - omit `content`
92
+ - **`cancel`**: User cancelled the entire operation - omit `content`
93
+
94
+ ## Basic Example
95
+
96
+ ```python
97
+ from fastmcp import Client
98
+ from fastmcp.client.elicitation import ElicitResult
99
+
100
+ async def basic_elicitation_handler(message: str, response_type: type, params, context) -> ElicitResult:
101
+ print(f"Server asks: {message}")
102
+
103
+ # Simple text input for demonstration
104
+ user_response = input("Your response: ")
105
+
106
+ if not user_response:
107
+ return ElicitResult(action="decline")
108
+
109
+ # Use the response_type dataclass to create a properly structured response
110
+ # FastMCP handles the conversion from JSON schema to Python type
111
+ return ElicitResult(action="accept", content=response_type(value=user_response))
112
+
113
+ client = Client(
114
+ "my_mcp_server.py",
115
+ elicitation_handler=basic_elicitation_handler
116
+ )
117
+ ```
118
+
119
+
docs/docs.json CHANGED
@@ -106,6 +106,7 @@
106
  "group": "Advanced Features",
107
  "icon": "stars",
108
  "pages": [
 
109
  "clients/logging",
110
  "clients/progress",
111
  "clients/sampling",
 
106
  "group": "Advanced Features",
107
  "icon": "stars",
108
  "pages": [
109
+ "clients/elicitation",
110
  "clients/logging",
111
  "clients/progress",
112
  "clients/sampling",
docs/servers/context.mdx CHANGED
@@ -16,6 +16,7 @@ The `Context` object provides a clean interface to access MCP features within yo
16
  - **Progress Reporting**: Update the client on the progress of long-running operations
17
  - **Resource Access**: Read data from resources registered with the server
18
  - **LLM Sampling**: Request the client's LLM to generate text based on provided messages
 
19
  - **Request Information**: Access metadata about the current request
20
  - **Server Access**: When needed, access the underlying FastMCP server instance
21
 
@@ -275,6 +276,116 @@ async def generate_example(concept: str, ctx: Context) -> str:
275
 
276
  See [Client Sampling](/clients/client#llm-sampling) for more details on how clients handle these requests.
277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  ### Component Changes
279
 
280
  <VersionBadge version="2.9.1" />
 
16
  - **Progress Reporting**: Update the client on the progress of long-running operations
17
  - **Resource Access**: Read data from resources registered with the server
18
  - **LLM Sampling**: Request the client's LLM to generate text based on provided messages
19
+ - **User Elicitation**: Request structured input from users during tool execution
20
  - **Request Information**: Access metadata about the current request
21
  - **Server Access**: When needed, access the underlying FastMCP server instance
22
 
 
276
 
277
  See [Client Sampling](/clients/client#llm-sampling) for more details on how clients handle these requests.
278
 
279
+ ### User Elicitation
280
+
281
+ <VersionBadge version="2.10.0" />
282
+
283
+ Request structured input from users during tool execution. This allows tools to interactively ask for missing parameters, clarification, or additional context as needed.
284
+
285
+ ```python
286
+ from dataclasses import dataclass
287
+
288
+ @dataclass
289
+ class UserInfo:
290
+ name: str
291
+ age: int
292
+
293
+ @mcp.tool
294
+ async def collect_user_info(ctx: Context) -> str:
295
+ """Collect user information through interactive prompts."""
296
+ # Request structured user information
297
+ result = await ctx.elicit(
298
+ message="Please provide your information",
299
+ response_type=UserInfo
300
+ )
301
+
302
+ if result.action == "accept":
303
+ user = result.data
304
+ return f"Hello {user.name}, you are {user.age} years old"
305
+ elif result.action == "decline":
306
+ return "Information not provided"
307
+ else: # cancel
308
+ return "Operation cancelled"
309
+ ```
310
+
311
+ **Method signature:**
312
+
313
+ - **`ctx.elicit(message: str, response_type: type = str) -> ElicitationResult`**
314
+ - `message`: The prompt message to display to the user
315
+ - `response_type`: The Python type defining the expected response structure (dataclass, primitive type, etc.)
316
+ - Returns an `ElicitationResult` with `action` ("accept", "decline", "cancel") and `data` (when accepted)
317
+
318
+ **Supported Response Types:**
319
+
320
+ - **Primitive types**: `str`, `int`, `float`, `bool`
321
+ - **Literal types**: `Literal["option1", "option2"]` for constrained choices
322
+ - **Enum types**: Python enums for predefined options
323
+ - **Dataclass types**: Custom structured data with multiple fields
324
+
325
+ ```python
326
+ from typing import Literal
327
+ from enum import Enum
328
+
329
+ class Priority(Enum):
330
+ LOW = "low"
331
+ MEDIUM = "medium"
332
+ HIGH = "high"
333
+
334
+ @dataclass
335
+ class TaskInfo:
336
+ title: str
337
+ priority: Priority
338
+ urgent: bool
339
+
340
+ @mcp.tool
341
+ async def create_task(ctx: Context) -> str:
342
+ """Create a task with user-provided details."""
343
+ # Multiple elicitation calls for different information
344
+
345
+ # Simple string input
346
+ title_result = await ctx.elicit("What's the task title?", response_type=str)
347
+ if title_result.action != "accept":
348
+ return "Task creation cancelled"
349
+
350
+ # Enum selection
351
+ priority_result = await ctx.elicit("What's the priority?", response_type=Priority)
352
+ if priority_result.action != "accept":
353
+ return "Task creation cancelled"
354
+
355
+ # Boolean choice
356
+ urgent_result = await ctx.elicit("Is this urgent?", response_type=bool)
357
+ if urgent_result.action != "accept":
358
+ return "Task creation cancelled"
359
+
360
+ return f"Created task: {title_result.data} (Priority: {priority_result.data.value}, Urgent: {urgent_result.data})"
361
+ ```
362
+
363
+ **Pattern Matching Support:**
364
+
365
+ FastMCP provides typed result classes for pattern matching:
366
+
367
+ ```python
368
+ from fastmcp.server.elicitation import (
369
+ AcceptedElicitation,
370
+ DeclinedElicitation,
371
+ CancelledElicitation
372
+ )
373
+
374
+ @mcp.tool
375
+ async def pattern_example(ctx: Context) -> str:
376
+ result = await ctx.elicit("Enter your name:", response_type=str)
377
+
378
+ match result:
379
+ case AcceptedElicitation(data=name):
380
+ return f"Hello {name}!"
381
+ case DeclinedElicitation():
382
+ return "No name provided"
383
+ case CancelledElicitation():
384
+ return "Operation cancelled"
385
+ ```
386
+
387
+ Elicitation requires the client to provide an elicitation handler. If the client doesn't support elicitation, the request will fail. See [Client Elicitation](/clients/elicitation) for details on implementing client-side handlers.
388
+
389
  ### Component Changes
390
 
391
  <VersionBadge version="2.9.1" />
src/fastmcp/client/elicitation.py CHANGED
@@ -1,24 +1,35 @@
1
  from __future__ import annotations
2
 
3
  from collections.abc import Awaitable, Callable
4
- from typing import Any, TypeAlias
5
 
6
  import mcp.types
7
  from mcp import ClientSession
8
  from mcp.client.session import ElicitationFnT
9
  from mcp.shared.context import LifespanContextT, RequestContext
10
- from mcp.types import ElicitRequestParams, ElicitResult
 
 
 
 
11
 
12
  __all__ = ["ElicitRequestParams", "ElicitResult", "ElicitationHandler"]
13
 
 
 
 
 
 
 
14
 
15
  ElicitationHandler: TypeAlias = Callable[
16
  [
17
  str, # message
18
- dict[str, Any], # requested_schema
 
19
  RequestContext[ClientSession, LifespanContextT],
20
  ],
21
- Awaitable[ElicitResult],
22
  ]
23
 
24
 
@@ -28,12 +39,15 @@ def create_elicitation_callback(
28
  async def _elicitation_handler(
29
  context: RequestContext[ClientSession, LifespanContextT],
30
  params: ElicitRequestParams,
31
- ) -> ElicitResult | mcp.types.ErrorData:
32
  try:
 
 
33
  result = await elicitation_handler(
34
- params.message, params.requestedSchema, context
35
  )
36
- return result
 
37
  except Exception as e:
38
  return mcp.types.ErrorData(
39
  code=mcp.types.INTERNAL_ERROR,
 
1
  from __future__ import annotations
2
 
3
  from collections.abc import Awaitable, Callable
4
+ from typing import Any, Generic, TypeAlias, TypeVar
5
 
6
  import mcp.types
7
  from mcp import ClientSession
8
  from mcp.client.session import ElicitationFnT
9
  from mcp.shared.context import LifespanContextT, RequestContext
10
+ from mcp.types import ElicitRequestParams
11
+ from mcp.types import ElicitResult as MCPElicitResult
12
+ from pydantic_core import to_jsonable_python
13
+
14
+ from fastmcp.utilities.json_schema_type import json_schema_to_type
15
 
16
  __all__ = ["ElicitRequestParams", "ElicitResult", "ElicitationHandler"]
17
 
18
+ T = TypeVar("T")
19
+
20
+
21
+ class ElicitResult(MCPElicitResult, Generic[T]):
22
+ content: T | None = None
23
+
24
 
25
  ElicitationHandler: TypeAlias = Callable[
26
  [
27
  str, # message
28
+ type[T], # a class for creating a structured response
29
+ ElicitRequestParams,
30
  RequestContext[ClientSession, LifespanContextT],
31
  ],
32
+ Awaitable[ElicitResult[T | dict[str, Any]]],
33
  ]
34
 
35
 
 
39
  async def _elicitation_handler(
40
  context: RequestContext[ClientSession, LifespanContextT],
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
48
  )
49
+ content = to_jsonable_python(result.content)
50
+ return MCPElicitResult(**result.model_dump() | {"content": content})
51
  except Exception as e:
52
  return mcp.types.ErrorData(
53
  code=mcp.types.INTERNAL_ERROR,
src/fastmcp/server/context.py CHANGED
@@ -30,7 +30,7 @@ from fastmcp.server.elicitation import (
30
  AcceptedElicitation,
31
  CancelledElicitation,
32
  DeclinedElicitation,
33
- PrimitiveElicitationType,
34
  get_elicitation_schema,
35
  )
36
  from fastmcp.server.server import FastMCP
@@ -339,7 +339,7 @@ class Context:
339
  response_type = str # type: ignore
340
 
341
  if response_type in {bool, int, float, str}:
342
- response_type = PrimitiveElicitationType[response_type] # type: ignore
343
 
344
  requested_schema = get_elicitation_schema(response_type) # type: ignore
345
 
@@ -352,10 +352,10 @@ class Context:
352
  if result.action == "accept" and result.content:
353
  type_adapter = get_cached_typeadapter(response_type)
354
  validated_data = cast(
355
- T | PrimitiveElicitationType[T],
356
  type_adapter.validate_python(result.content),
357
  )
358
- if isinstance(validated_data, PrimitiveElicitationType):
359
  return AcceptedElicitation[T](data=validated_data.value)
360
  else:
361
  return AcceptedElicitation[T](data=validated_data)
 
30
  AcceptedElicitation,
31
  CancelledElicitation,
32
  DeclinedElicitation,
33
+ ScalarElicitationType,
34
  get_elicitation_schema,
35
  )
36
  from fastmcp.server.server import FastMCP
 
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
 
 
352
  if result.action == "accept" and result.content:
353
  type_adapter = get_cached_typeadapter(response_type)
354
  validated_data = cast(
355
+ T | ScalarElicitationType[T],
356
  type_adapter.validate_python(result.content),
357
  )
358
+ if isinstance(validated_data, ScalarElicitationType):
359
  return AcceptedElicitation[T](data=validated_data.value)
360
  else:
361
  return AcceptedElicitation[T](data=validated_data)
src/fastmcp/server/elicitation.py CHANGED
@@ -18,7 +18,7 @@ __all__ = [
18
  "CancelledElicitation",
19
  "DeclinedElicitation",
20
  "get_elicitation_schema",
21
- "PrimitiveElicitationType",
22
  ]
23
 
24
  logger = get_logger(__name__)
@@ -35,7 +35,7 @@ class AcceptedElicitation(BaseModel, Generic[T]):
35
 
36
 
37
  @dataclass
38
- class PrimitiveElicitationType(Generic[T]):
39
  value: T
40
 
41
 
@@ -62,6 +62,7 @@ def validate_elicitation_json_schema(schema: dict[str, Any]) -> None:
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:
@@ -98,10 +99,41 @@ def validate_elicitation_json_schema(schema: dict[str, Any]) -> None:
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(
 
18
  "CancelledElicitation",
19
  "DeclinedElicitation",
20
  "get_elicitation_schema",
21
+ "ScalarElicitationType",
22
  ]
23
 
24
  logger = get_logger(__name__)
 
35
 
36
 
37
  @dataclass
38
+ class ScalarElicitationType(Generic[T]):
39
  value: T
40
 
41
 
 
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
+ - Allows const fields (for Literal types) and enum fields (for Enum types)
66
  - Only primitive types and their nullable variants are allowed
67
 
68
  Args:
 
99
  elif prop_schema.get("nullable", False):
100
  continue # Nullable with no other type is fine
101
 
102
+ # Handle const fields (Literal types)
103
+ if "const" in prop_schema:
104
+ continue # const fields are allowed regardless of type
105
+
106
+ # Handle enum fields (Enum types)
107
+ if "enum" in prop_schema:
108
+ continue # enum fields are allowed regardless of type
109
+
110
+ # Handle references to definitions (like Enum types)
111
+ if "$ref" in prop_schema:
112
+ # Get the referenced definition
113
+ ref_path = prop_schema["$ref"]
114
+ if ref_path.startswith("#/$defs/"):
115
+ def_name = ref_path[8:] # Remove "#/$defs/" prefix
116
+ ref_def = schema.get("$defs", {}).get(def_name, {})
117
+ # If the referenced definition has an enum, it's allowed
118
+ if "enum" in ref_def:
119
+ continue
120
+ # If the referenced definition has a type that's allowed, it's allowed
121
+ ref_type = ref_def.get("type")
122
+ if ref_type in ALLOWED_TYPES:
123
+ continue
124
+ # If we can't determine what the ref points to, reject it for safety
125
+ raise TypeError(
126
+ f"Elicitation schema field '{prop_name}' contains a reference '{ref_path}' "
127
+ "that could not be validated. Only references to enum types or primitive types are allowed."
128
+ )
129
+
130
  # Handle union types (oneOf/anyOf)
131
  if "oneOf" in prop_schema or "anyOf" in prop_schema:
132
  union_schemas = prop_schema.get("oneOf", []) + prop_schema.get("anyOf", [])
133
  for union_schema in union_schemas:
134
+ # Allow const and enum in unions
135
+ if "const" in union_schema or "enum" in union_schema:
136
+ continue
137
  union_type = union_schema.get("type")
138
  if union_type not in ALLOWED_TYPES:
139
  raise TypeError(
src/fastmcp/utilities/json_schema_type.py CHANGED
@@ -41,7 +41,6 @@ from collections.abc import Callable, Mapping
41
  from copy import deepcopy
42
  from dataclasses import MISSING, field, make_dataclass
43
  from datetime import datetime
44
- from enum import Enum
45
  from typing import (
46
  Annotated,
47
  Any,
@@ -254,8 +253,7 @@ def _create_numeric_type(
254
 
255
  def _create_enum(name: str, values: list[Any]) -> type:
256
  """Create enum type from list of values."""
257
- if all(isinstance(v, str) for v in values):
258
- return Enum(name, {v.upper(): v for v in values}) # type: ignore[return-value]
259
  return Literal[tuple(values)] # type: ignore[return-value]
260
 
261
 
@@ -399,15 +397,19 @@ def _schema_to_type(
399
 
400
  def _sanitize_name(name: str) -> str:
401
  """Convert string to valid Python identifier."""
 
402
  # Step 1: replace everything except [0-9a-zA-Z_] with underscores
403
  cleaned = re.sub(r"[^0-9a-zA-Z_]", "_", name)
404
  # Step 2: deduplicate underscores
405
  cleaned = re.sub(r"__+", "_", cleaned)
406
- # Step 3: if the first char of original name isn't a letter, prepend field_
407
- if not name or not re.match(r"[a-zA-Z]", name[0]):
408
  cleaned = f"field_{cleaned}"
409
- # Step 4: deduplicate again and strip trailing underscores
410
- cleaned = re.sub(r"__+", "_", cleaned).strip("_")
 
 
 
411
  return cleaned
412
 
413
 
 
41
  from copy import deepcopy
42
  from dataclasses import MISSING, field, make_dataclass
43
  from datetime import datetime
 
44
  from typing import (
45
  Annotated,
46
  Any,
 
253
 
254
  def _create_enum(name: str, values: list[Any]) -> type:
255
  """Create enum type from list of values."""
256
+ # Always return Literal for enum fields to preserve the literal nature
 
257
  return Literal[tuple(values)] # type: ignore[return-value]
258
 
259
 
 
397
 
398
  def _sanitize_name(name: str) -> str:
399
  """Convert string to valid Python identifier."""
400
+ original_name = name
401
  # Step 1: replace everything except [0-9a-zA-Z_] with underscores
402
  cleaned = re.sub(r"[^0-9a-zA-Z_]", "_", name)
403
  # Step 2: deduplicate underscores
404
  cleaned = re.sub(r"__+", "_", cleaned)
405
+ # Step 3: if the first char of original name isn't a letter or underscore, prepend field_
406
+ if not name or not re.match(r"[a-zA-Z_]", name[0]):
407
  cleaned = f"field_{cleaned}"
408
+ # Step 4: deduplicate again
409
+ cleaned = re.sub(r"__+", "_", cleaned)
410
+ # Step 5: only strip trailing underscores if they weren't in the original name
411
+ if not original_name.endswith("_"):
412
+ cleaned = cleaned.rstrip("_")
413
  return cleaned
414
 
415
 
tests/client/test_elicitation.py CHANGED
@@ -1,16 +1,19 @@
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.exceptions import ToolError
9
  from fastmcp.server.elicitation import (
10
  AcceptedElicitation,
11
  CancelledElicitation,
12
  DeclinedElicitation,
13
  )
 
14
 
15
 
16
  @pytest.fixture
@@ -50,9 +53,9 @@ async def test_elicitation_with_no_handler(fastmcp_server):
50
  async def test_elicitation_accept_content(fastmcp_server):
51
  """Test basic elicitation functionality."""
52
 
53
- async def elicitation_handler(message, schema, ctx):
54
  # Mock user providing their name
55
- return ElicitResult(action="accept", content={"name": "Alice"})
56
 
57
  async with Client(
58
  fastmcp_server, elicitation_handler=elicitation_handler
@@ -64,7 +67,7 @@ async def test_elicitation_accept_content(fastmcp_server):
64
  async def test_elicitation_decline(fastmcp_server):
65
  """Test that elicitation handler receives correct parameters."""
66
 
67
- async def elicitation_handler(message, schema, ctx):
68
  return ElicitResult(action="decline")
69
 
70
  async with Client(
@@ -89,7 +92,7 @@ async def test_default_response_type(fastmcp_server):
89
  return f"Your favorite color is {result.data}!"
90
  return "No color provided"
91
 
92
- async def elicitation_handler(message, schema, ctx):
93
  # Mock user providing their favorite color as string in content dict
94
  return ElicitResult(action="accept", content={"value": "blue"})
95
 
@@ -111,9 +114,10 @@ async def test_elicitation_handler_parameters():
111
  )
112
  return "done"
113
 
114
- async def elicitation_handler(message, schema, ctx):
115
  captured_params["message"] = message
116
- captured_params["schema"] = schema
 
117
  captured_params["ctx"] = ctx
118
  return ElicitResult(action="accept", content={"value": 42})
119
 
@@ -121,45 +125,16 @@ async def test_elicitation_handler_parameters():
121
  await client.call_tool("test_tool", {})
122
 
123
  assert captured_params["message"] == "Test message"
124
- assert captured_params["schema"] == {
 
125
  "properties": {"value": {"title": "Value", "type": "integer"}},
126
  "required": ["value"],
127
- "title": "PrimitiveElicitationType",
128
  "type": "object",
129
  }
130
  assert captured_params["ctx"] is not None
131
 
132
 
133
- async def test_elicitation_default_string_schema():
134
- """Test elicitation with default string schema."""
135
- mcp = FastMCP("TestServer")
136
-
137
- @mcp.tool
138
- async def ask_for_input(context: Context) -> str:
139
- result = await context.elicit(
140
- message="Please provide some input"
141
- # No schema provided - should default to string
142
- )
143
- if result.action == "accept":
144
- return f"You said: {result.data}"
145
- return "No input provided"
146
-
147
- async def elicitation_handler(message, schema, ctx):
148
- # Verify default schema is wrapped string object
149
- expected_schema = {
150
- "properties": {"value": {"title": "Value", "type": "string"}},
151
- "required": ["value"],
152
- "title": "PrimitiveElicitationType",
153
- "type": "object",
154
- }
155
- assert schema == expected_schema
156
- return ElicitResult(action="accept", content={"value": "Hello world!"})
157
-
158
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
159
- result = await client.call_tool("ask_for_input", {})
160
- assert result.data == "You said: Hello world!"
161
-
162
-
163
  async def test_elicitation_cancel_action():
164
  """Test user canceling elicitation request."""
165
  mcp = FastMCP("TestServer")
@@ -176,7 +151,7 @@ async def test_elicitation_cancel_action():
176
  else:
177
  return "No response provided"
178
 
179
- async def elicitation_handler(message, schema, ctx):
180
  return ElicitResult(action="cancel")
181
 
182
  async with Client(mcp, elicitation_handler=elicitation_handler) as client:
@@ -195,15 +170,8 @@ async def test_elicitation_number_schema():
195
  return f"You are {result.data} years old"
196
  return "No age provided"
197
 
198
- async def elicitation_handler(message, schema, ctx):
199
- expected_schema = {
200
- "properties": {"value": {"title": "Value", "type": "integer"}},
201
- "required": ["value"],
202
- "title": "PrimitiveElicitationType",
203
- "type": "object",
204
- }
205
- assert schema == expected_schema
206
- return ElicitResult(action="accept", content={"value": 25})
207
 
208
  async with Client(mcp, elicitation_handler=elicitation_handler) as client:
209
  result = await client.call_tool("get_age", {})
@@ -223,7 +191,7 @@ async def test_elicitation_handler_error():
223
  except Exception as e:
224
  return f"Error: {str(e)}"
225
 
226
- async def elicitation_handler(message, schema, ctx):
227
  raise ValueError("Handler failed!")
228
 
229
  async with Client(mcp, elicitation_handler=elicitation_handler) as client:
@@ -253,28 +221,12 @@ async def test_elicitation_multiple_calls():
253
 
254
  call_count = 0
255
 
256
- async def elicitation_handler(message, schema, ctx):
257
  nonlocal call_count
258
  call_count += 1
259
  if call_count == 1:
260
- assert "name" in message.lower()
261
- expected_schema = {
262
- "properties": {"value": {"title": "Value", "type": "string"}},
263
- "required": ["value"],
264
- "title": "PrimitiveElicitationType",
265
- "type": "object",
266
- }
267
- assert schema == expected_schema
268
  return ElicitResult(action="accept", content={"value": "Bob"})
269
  elif call_count == 2:
270
- assert "age" in message.lower()
271
- expected_schema = {
272
- "properties": {"value": {"title": "Value", "type": "integer"}},
273
- "required": ["value"],
274
- "title": "PrimitiveElicitationType",
275
- "type": "object",
276
- }
277
- assert schema == expected_schema
278
  return ElicitResult(action="accept", content={"value": 25})
279
  else:
280
  raise ValueError("Unexpected call")
@@ -300,150 +252,139 @@ async def test_dataclass_response_type():
300
  message="Please provide your information", response_type=UserInfo
301
  )
302
  if result.action == "accept":
303
- user = result.data
304
- return f"User: {user.name}, age: {user.age}"
305
  return "No user info provided"
306
 
307
- async def elicitation_handler(message, schema, ctx):
308
- # Verify the schema has the dataclass fields
 
 
 
 
 
 
 
309
  assert schema["type"] == "object"
310
  assert "name" in schema["properties"]
311
  assert "age" in schema["properties"]
312
  assert schema["properties"]["name"]["type"] == "string"
313
  assert schema["properties"]["age"]["type"] == "integer"
314
 
315
- return ElicitResult(action="accept", content={"name": "Alice", "age": 30})
316
 
317
  async with Client(mcp, elicitation_handler=elicitation_handler) as client:
318
  result = await client.call_tool("get_user_info", {})
319
  assert result.data == "User: Alice, age: 30"
320
 
321
 
322
- async def test_primitive_type_string():
323
- """Test elicitation with string primitive type."""
324
- mcp = FastMCP("TestServer")
325
-
326
- @mcp.tool
327
- async def test_string(context: Context) -> str:
328
- result = await context.elicit("Enter text:", response_type=str)
329
- assert result.action == "accept"
330
- return f"Got: {result.data}"
331
-
332
- async def elicitation_handler(message, schema, ctx):
333
- assert schema["properties"]["value"]["type"] == "string"
334
- return ElicitResult(action="accept", content={"value": "hello"})
335
-
336
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
337
- result = await client.call_tool("test_string", {})
338
- assert result.data == "Got: hello"
339
-
340
-
341
- async def test_primitive_type_int():
342
- """Test elicitation with integer primitive type."""
343
- mcp = FastMCP("TestServer")
344
-
345
- @mcp.tool
346
- async def test_int(context: Context) -> str:
347
- result = await context.elicit("Enter number:", response_type=int)
348
- assert result.action == "accept"
349
- return f"Got: {result.data}"
350
-
351
- async def elicitation_handler(message, schema, ctx):
352
- assert schema["properties"]["value"]["type"] == "integer"
353
- return ElicitResult(action="accept", content={"value": 42})
354
-
355
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
356
- result = await client.call_tool("test_int", {})
357
- assert result.data == "Got: 42"
358
-
359
-
360
- async def test_primitive_type_float():
361
- """Test elicitation with float primitive type."""
362
- mcp = FastMCP("TestServer")
363
-
364
- @mcp.tool
365
- async def test_float(context: Context) -> str:
366
- result = await context.elicit("Enter decimal:", response_type=float)
367
- assert result.action == "accept"
368
- return f"Got: {result.data}"
369
-
370
- async def elicitation_handler(message, schema, ctx):
371
- assert schema["properties"]["value"]["type"] == "number"
372
- return ElicitResult(action="accept", content={"value": 3.14})
373
-
374
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
375
- result = await client.call_tool("test_float", {})
376
- assert result.data == "Got: 3.14"
377
 
 
 
 
 
 
 
 
 
 
 
 
378
 
379
- async def test_primitive_type_bool():
380
- """Test elicitation with boolean primitive type."""
381
  mcp = FastMCP("TestServer")
382
 
383
  @mcp.tool
384
- async def test_bool(context: Context) -> str:
385
- result = await context.elicit("Enter true/false:", response_type=bool)
386
- assert result.action == "accept"
387
- return f"Got: {result.data}"
388
-
389
- async def elicitation_handler(message, schema, ctx):
390
- assert schema["properties"]["value"]["type"] == "boolean"
391
- return ElicitResult(action="accept", content={"value": True})
392
-
393
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
394
- result = await client.call_tool("test_bool", {})
395
- assert result.data == "Got: True"
396
-
397
-
398
- async def test_schema_validation_rejects_non_object():
399
- """Test that non-object schemas are rejected."""
400
- from fastmcp.server.elicitation import validate_elicitation_json_schema
401
-
402
- with pytest.raises(TypeError, match="must be an object schema"):
403
- validate_elicitation_json_schema({"type": "string"})
404
-
405
-
406
- async def test_schema_validation_rejects_empty_object():
407
- """Test that object schemas without properties are rejected."""
408
- from fastmcp.server.elicitation import validate_elicitation_json_schema
409
-
410
- with pytest.raises(TypeError, match="must have at least one property"):
411
- validate_elicitation_json_schema({"type": "object"})
412
-
413
-
414
- async def test_schema_validation_rejects_nested_objects():
415
- """Test that nested object schemas are rejected."""
416
- from fastmcp.server.elicitation import validate_elicitation_json_schema
417
-
418
- with pytest.raises(
419
- TypeError, match="has type 'object' which is not a primitive type"
420
- ):
421
- validate_elicitation_json_schema(
422
- {
423
- "type": "object",
424
- "properties": {
425
- "user": {
426
- "type": "object",
427
- "properties": {"name": {"type": "string"}},
428
- }
429
- },
430
- }
431
  )
432
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
433
 
434
- async def test_schema_validation_rejects_arrays():
435
- """Test that array schemas are rejected."""
436
- from fastmcp.server.elicitation import validate_elicitation_json_schema
437
 
438
- with pytest.raises(
439
- TypeError, match="has type 'array' which is not a primitive type"
440
- ):
441
- validate_elicitation_json_schema(
442
- {
443
- "type": "object",
444
- "properties": {"users": {"type": "array", "items": {"type": "string"}}},
445
- }
446
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
 
448
 
449
  async def test_pattern_matching_accept():
@@ -462,7 +403,7 @@ async def test_pattern_matching_accept():
462
  case CancelledElicitation():
463
  return "Cancelled"
464
 
465
- async def elicitation_handler(message, schema, ctx):
466
  return ElicitResult(action="accept", content={"value": "Alice"})
467
 
468
  async with Client(mcp, elicitation_handler=elicitation_handler) as client:
@@ -486,7 +427,7 @@ async def test_pattern_matching_decline():
486
  case CancelledElicitation():
487
  return "Cancelled"
488
 
489
- async def elicitation_handler(message, schema, ctx):
490
  return ElicitResult(action="decline")
491
 
492
  async with Client(mcp, elicitation_handler=elicitation_handler) as client:
@@ -510,7 +451,7 @@ async def test_pattern_matching_cancel():
510
  case CancelledElicitation():
511
  return "Cancelled"
512
 
513
- async def elicitation_handler(message, schema, ctx):
514
  return ElicitResult(action="cancel")
515
 
516
  async with Client(mcp, elicitation_handler=elicitation_handler) as client:
 
1
+ from dataclasses import asdict, dataclass
2
+ 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
9
+ from fastmcp.client.elicitation import ElicitResult
10
  from fastmcp.exceptions import ToolError
11
  from fastmcp.server.elicitation import (
12
  AcceptedElicitation,
13
  CancelledElicitation,
14
  DeclinedElicitation,
15
  )
16
+ from fastmcp.utilities.types import TypeAdapter
17
 
18
 
19
  @pytest.fixture
 
53
  async def test_elicitation_accept_content(fastmcp_server):
54
  """Test basic elicitation functionality."""
55
 
56
+ async def elicitation_handler(message, response_type, params, ctx):
57
  # Mock user providing their name
58
+ return ElicitResult(action="accept", content=response_type(name="Alice"))
59
 
60
  async with Client(
61
  fastmcp_server, elicitation_handler=elicitation_handler
 
67
  async def test_elicitation_decline(fastmcp_server):
68
  """Test that elicitation handler receives correct parameters."""
69
 
70
+ async def elicitation_handler(message, response_type, params, ctx):
71
  return ElicitResult(action="decline")
72
 
73
  async with Client(
 
92
  return f"Your favorite color is {result.data}!"
93
  return "No color provided"
94
 
95
+ async def elicitation_handler(message, response_type, params, ctx):
96
  # Mock user providing their favorite color as string in content dict
97
  return ElicitResult(action="accept", content={"value": "blue"})
98
 
 
114
  )
115
  return "done"
116
 
117
+ async def elicitation_handler(message, response_type, params, ctx):
118
  captured_params["message"] = message
119
+ captured_params["response_type"] = str(response_type)
120
+ captured_params["params"] = params
121
  captured_params["ctx"] = ctx
122
  return ElicitResult(action="accept", content={"value": 42})
123
 
 
125
  await client.call_tool("test_tool", {})
126
 
127
  assert captured_params["message"] == "Test message"
128
+ assert "ScalarElicitationType" in str(captured_params["response_type"])
129
+ assert captured_params["params"].requestedSchema == {
130
  "properties": {"value": {"title": "Value", "type": "integer"}},
131
  "required": ["value"],
132
+ "title": "ScalarElicitationType",
133
  "type": "object",
134
  }
135
  assert captured_params["ctx"] is not None
136
 
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  async def test_elicitation_cancel_action():
139
  """Test user canceling elicitation request."""
140
  mcp = FastMCP("TestServer")
 
151
  else:
152
  return "No response provided"
153
 
154
+ async def elicitation_handler(message, response_type, params, ctx):
155
  return ElicitResult(action="cancel")
156
 
157
  async with Client(mcp, elicitation_handler=elicitation_handler) as client:
 
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", {})
 
191
  except Exception as e:
192
  return f"Error: {str(e)}"
193
 
194
+ async def elicitation_handler(message, response_type, params, ctx):
195
  raise ValueError("Handler failed!")
196
 
197
  async with Client(mcp, elicitation_handler=elicitation_handler) as client:
 
221
 
222
  call_count = 0
223
 
224
+ async def elicitation_handler(message, response_type, params, ctx):
225
  nonlocal call_count
226
  call_count += 1
227
  if call_count == 1:
 
 
 
 
 
 
 
 
228
  return ElicitResult(action="accept", content={"value": "Bob"})
229
  elif call_count == 2:
 
 
 
 
 
 
 
 
230
  return ElicitResult(action="accept", content={"value": 25})
231
  else:
232
  raise ValueError("Unexpected call")
 
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)
266
+ schema = params.requestedSchema
267
  assert schema["type"] == "object"
268
  assert "name" in schema["properties"]
269
  assert "age" in schema["properties"]
270
  assert schema["properties"]["name"]["type"] == "string"
271
  assert schema["properties"]["age"]["type"] == "integer"
272
 
273
+ return ElicitResult(action="accept", content=UserInfo(name="Alice", age=30))
274
 
275
  async with Client(mcp, elicitation_handler=elicitation_handler) as client:
276
  result = await client.call_tool("get_user_info", {})
277
  assert result.data == "User: Alice, age: 30"
278
 
279
 
280
+ async def test_all_primitive_field_types():
281
+ class DataEnum(Enum):
282
+ X = "x"
283
+ Y = "y"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
+ @dataclass
286
+ class Data:
287
+ integer: int
288
+ float_: float
289
+ number: int | float
290
+ boolean: bool
291
+ string: str
292
+ constant: Literal["x"]
293
+ union: Literal["x"] | Literal["y"]
294
+ choice: Literal["x", "y"]
295
+ enum: DataEnum
296
 
 
 
297
  mcp = FastMCP("TestServer")
298
 
299
  @mcp.tool
300
+ async def get_data(context: Context) -> Data:
301
+ result = await context.elicit(message="Enter data", response_type=Data)
302
+ return result.data # type: ignore[attr-defined]
303
+
304
+ async def elicitation_handler(message, response_type, params, ctx):
305
+ return ElicitResult(
306
+ action="accept",
307
+ content=Data(
308
+ integer=1,
309
+ float_=1.0,
310
+ number=1.0,
311
+ boolean=True,
312
+ string="hello",
313
+ constant="x",
314
+ union="x",
315
+ choice="x",
316
+ enum=DataEnum.X,
317
+ ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  )
319
 
320
+ async with Client(mcp, elicitation_handler=elicitation_handler) as client:
321
+ result = await client.call_tool("get_data", {})
322
+
323
+ # Now all literal/enum fields should be preserved as strings
324
+ result_data = asdict(result.data)
325
+ result_data_enum = result_data.pop("enum")
326
+ assert result_data_enum == "x" # Should be a string now, not an enum
327
+ assert result_data == {
328
+ "integer": 1,
329
+ "float_": 1.0,
330
+ "number": 1.0,
331
+ "boolean": True,
332
+ "string": "hello",
333
+ "constant": "x",
334
+ "union": "x",
335
+ "choice": "x",
336
+ }
337
 
 
 
 
338
 
339
+ class TestValidation:
340
+ async def test_schema_validation_rejects_non_object(self):
341
+ """Test that non-object schemas are rejected."""
342
+ from fastmcp.server.elicitation import validate_elicitation_json_schema
343
+
344
+ with pytest.raises(TypeError, match="must be an object schema"):
345
+ validate_elicitation_json_schema({"type": "string"})
346
+
347
+ async def test_schema_validation_rejects_empty_object(self):
348
+ """Test that object schemas without properties are rejected."""
349
+ from fastmcp.server.elicitation import validate_elicitation_json_schema
350
+
351
+ with pytest.raises(TypeError, match="must have at least one property"):
352
+ validate_elicitation_json_schema({"type": "object"})
353
+
354
+ async def test_schema_validation_rejects_nested_objects(self):
355
+ """Test that nested object schemas are rejected."""
356
+ from fastmcp.server.elicitation import validate_elicitation_json_schema
357
+
358
+ with pytest.raises(
359
+ TypeError, match="has type 'object' which is not a primitive type"
360
+ ):
361
+ validate_elicitation_json_schema(
362
+ {
363
+ "type": "object",
364
+ "properties": {
365
+ "user": {
366
+ "type": "object",
367
+ "properties": {"name": {"type": "string"}},
368
+ }
369
+ },
370
+ }
371
+ )
372
+
373
+ async def test_schema_validation_rejects_arrays(self):
374
+ """Test that array schemas are rejected."""
375
+ from fastmcp.server.elicitation import validate_elicitation_json_schema
376
+
377
+ with pytest.raises(
378
+ TypeError, match="has type 'array' which is not a primitive type"
379
+ ):
380
+ validate_elicitation_json_schema(
381
+ {
382
+ "type": "object",
383
+ "properties": {
384
+ "users": {"type": "array", "items": {"type": "string"}}
385
+ },
386
+ }
387
+ )
388
 
389
 
390
  async def test_pattern_matching_accept():
 
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:
 
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:
 
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:
tests/utilities/test_json_schema_type.py CHANGED
@@ -1,5 +1,7 @@
 
1
  from datetime import datetime
2
- from typing import Any, Union
 
3
 
4
  import pytest
5
  from pydantic import AnyUrl, BaseModel, TypeAdapter, ValidationError
@@ -106,6 +108,65 @@ class TestSimpleTypes:
106
  validator.validate_python(False)
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  class TestStringConstraints:
110
  """Test suite for string constraint validation."""
111
 
@@ -386,6 +447,29 @@ class TestObjectTypes:
386
  with pytest.raises(ValidationError):
387
  validator.validate_python({"user": {"age": 30}})
388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
 
390
  class TestDefaultValues:
391
  """Test suite for default value handling."""
 
1
+ from dataclasses import dataclass
2
  from datetime import datetime
3
+ from enum import Enum
4
+ from typing import Any, Literal, Union
5
 
6
  import pytest
7
  from pydantic import AnyUrl, BaseModel, TypeAdapter, ValidationError
 
108
  validator.validate_python(False)
109
 
110
 
111
+ class TestConstrainedTypes:
112
+ def test_constant(self):
113
+ validator = TypeAdapter(Literal["x"])
114
+ schema = validator.json_schema()
115
+ type_ = json_schema_to_type(schema)
116
+ assert type_ == Literal["x"]
117
+ assert TypeAdapter(type_).validate_python("x") == "x"
118
+ with pytest.raises(ValidationError):
119
+ TypeAdapter(type_).validate_python("y")
120
+
121
+ def test_union_constants(self):
122
+ validator = TypeAdapter(Literal["x"] | Literal["y"])
123
+ schema = validator.json_schema()
124
+ type_ = json_schema_to_type(schema)
125
+ assert type_ == Literal["x"] | Literal["y"]
126
+ assert TypeAdapter(type_).validate_python("x") == "x"
127
+ assert TypeAdapter(type_).validate_python("y") == "y"
128
+ with pytest.raises(ValidationError):
129
+ TypeAdapter(type_).validate_python("z")
130
+
131
+ def test_enum_str(self):
132
+ class MyEnum(Enum):
133
+ X = "x"
134
+ Y = "y"
135
+
136
+ validator = TypeAdapter(MyEnum)
137
+ schema = validator.json_schema()
138
+ type_ = json_schema_to_type(schema)
139
+ assert type_ == Literal["x", "y"]
140
+ assert TypeAdapter(type_).validate_python("x") == "x"
141
+ assert TypeAdapter(type_).validate_python("y") == "y"
142
+ with pytest.raises(ValidationError):
143
+ TypeAdapter(type_).validate_python("z")
144
+
145
+ def test_enum_int(self):
146
+ class MyEnum(Enum):
147
+ X = 1
148
+ Y = 2
149
+
150
+ validator = TypeAdapter(MyEnum)
151
+ schema = validator.json_schema()
152
+ type_ = json_schema_to_type(schema)
153
+ assert type_ == Literal[1, 2]
154
+ assert TypeAdapter(type_).validate_python(1) == 1
155
+ assert TypeAdapter(type_).validate_python(2) == 2
156
+ with pytest.raises(ValidationError):
157
+ TypeAdapter(type_).validate_python(3)
158
+
159
+ def test_choice(self):
160
+ validator = TypeAdapter(Literal["x", "y"])
161
+ schema = validator.json_schema()
162
+ type_ = json_schema_to_type(schema)
163
+ assert type_ == Literal["x", "y"]
164
+ assert TypeAdapter(type_).validate_python("x") == "x"
165
+ assert TypeAdapter(type_).validate_python("y") == "y"
166
+ with pytest.raises(ValidationError):
167
+ TypeAdapter(type_).validate_python("z")
168
+
169
+
170
  class TestStringConstraints:
171
  """Test suite for string constraint validation."""
172
 
 
447
  with pytest.raises(ValidationError):
448
  validator.validate_python({"user": {"age": 30}})
449
 
450
+ def test_object_with_underscore_names(self):
451
+ @dataclass
452
+ class Data:
453
+ x: int
454
+ x_: int
455
+ _x: int
456
+
457
+ schema = TypeAdapter(Data).json_schema()
458
+ assert schema == {
459
+ "title": "Data",
460
+ "type": "object",
461
+ "properties": {
462
+ "x": {"type": "integer", "title": "X"},
463
+ "x_": {"type": "integer", "title": "X"},
464
+ "_x": {"type": "integer", "title": "X"},
465
+ },
466
+ "required": ["x", "x_", "_x"],
467
+ }
468
+
469
+ object = json_schema_to_type(schema)
470
+ object_schema = TypeAdapter(object).json_schema()
471
+ assert object_schema == schema
472
+
473
 
474
  class TestDefaultValues:
475
  """Test suite for default value handling."""