Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
815bac1
1
Parent(s): 1f71f65
Provide a response type to client
Browse files- CLAUDE.md +6 -1
- docs/clients/elicitation.mdx +119 -0
- docs/docs.json +1 -0
- docs/servers/context.mdx +111 -0
- src/fastmcp/client/elicitation.py +21 -7
- src/fastmcp/server/context.py +4 -4
- src/fastmcp/server/elicitation.py +34 -2
- src/fastmcp/utilities/json_schema_type.py +9 -7
- tests/client/test_elicitation.py +133 -192
- tests/utilities/test_json_schema_type.py +85 -1
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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
__all__ = ["ElicitRequestParams", "ElicitResult", "ElicitationHandler"]
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
ElicitationHandler: TypeAlias = Callable[
|
| 16 |
[
|
| 17 |
str, # message
|
| 18 |
-
|
|
|
|
| 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 |
-
) ->
|
| 32 |
try:
|
|
|
|
|
|
|
| 33 |
result = await elicitation_handler(
|
| 34 |
-
params.message, params
|
| 35 |
)
|
| 36 |
-
|
|
|
|
| 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 |
-
|
| 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 =
|
| 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 |
|
| 356 |
type_adapter.validate_python(result.content),
|
| 357 |
)
|
| 358 |
-
if isinstance(validated_data,
|
| 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 |
-
"
|
| 22 |
]
|
| 23 |
|
| 24 |
logger = get_logger(__name__)
|
|
@@ -35,7 +35,7 @@ class AcceptedElicitation(BaseModel, Generic[T]):
|
|
| 35 |
|
| 36 |
|
| 37 |
@dataclass
|
| 38 |
-
class
|
| 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 |
-
|
| 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-
|
| 408 |
cleaned = f"field_{cleaned}"
|
| 409 |
-
# Step 4: deduplicate again
|
| 410 |
-
cleaned = re.sub(r"__+", "_", cleaned)
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 54 |
# Mock user providing their name
|
| 55 |
-
return ElicitResult(action="accept", content=
|
| 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,
|
| 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,
|
| 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,
|
| 115 |
captured_params["message"] = message
|
| 116 |
-
captured_params["
|
|
|
|
| 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["
|
|
|
|
| 125 |
"properties": {"value": {"title": "Value", "type": "integer"}},
|
| 126 |
"required": ["value"],
|
| 127 |
-
"title": "
|
| 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,
|
| 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,
|
| 199 |
-
|
| 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,
|
| 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,
|
| 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 |
-
|
| 304 |
-
return f"User: {user.name}, age: {user.age}"
|
| 305 |
return "No user info provided"
|
| 306 |
|
| 307 |
-
async def elicitation_handler(message,
|
| 308 |
-
# Verify
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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
|
| 323 |
-
|
| 324 |
-
|
| 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
|
| 385 |
-
result = await context.elicit("Enter
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 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 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
validate_elicitation_json_schema
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 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,
|
| 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,
|
| 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,
|
| 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
|
|
|
|
| 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."""
|