Spaces:
Running
Running
Merge pull request #889 from jlowin/elicitation
Browse files- CLAUDE.md +6 -1
- docs/clients/elicitation.mdx +120 -0
- docs/docs.json +13 -2
- docs/servers/context.mdx +63 -194
- docs/servers/elicitation.mdx +304 -0
- docs/servers/logging.mdx +194 -0
- docs/servers/progress.mdx +189 -0
- docs/servers/sampling.mdx +190 -0
- src/fastmcp/client/client.py +16 -0
- src/fastmcp/client/elicitation.py +57 -0
- src/fastmcp/client/transports.py +8 -1
- src/fastmcp/server/context.py +92 -2
- src/fastmcp/server/elicitation.py +165 -0
- src/fastmcp/utilities/json_schema_type.py +9 -7
- tests/client/test_elicitation.py +578 -0
- 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,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: User Elicitation
|
| 3 |
+
sidebarTitle: Elicitation
|
| 4 |
+
description: Handle server-initiated user input requests with structured schemas.
|
| 5 |
+
icon: message-question
|
| 6 |
+
tag: NEW
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
import { VersionBadge } from "/snippets/version-badge.mdx";
|
| 10 |
+
|
| 11 |
+
<VersionBadge version="2.10.0" />
|
| 12 |
+
|
| 13 |
+
## What is Elicitation?
|
| 14 |
+
|
| 15 |
+
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.
|
| 16 |
+
|
| 17 |
+
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?"
|
| 18 |
+
|
| 19 |
+
## How FastMCP Makes Elicitation Easy
|
| 20 |
+
|
| 21 |
+
FastMCP's client provides a helpful abstraction layer that:
|
| 22 |
+
|
| 23 |
+
- **Converts JSON schemas to Python types**: The raw MCP protocol uses JSON schemas, but FastMCP automatically converts these to Python dataclasses
|
| 24 |
+
- **Provides structured constructors**: Instead of manually building dictionaries that match the schema, you get dataclass constructors that ensure correct structure
|
| 25 |
+
- **Handles type conversion**: FastMCP takes care of converting between JSON representations and Python objects
|
| 26 |
+
- **Runtime introspection**: You can inspect the generated dataclass fields to understand the expected structure
|
| 27 |
+
|
| 28 |
+
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.
|
| 29 |
+
|
| 30 |
+
## Elicitation Handler
|
| 31 |
+
|
| 32 |
+
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:
|
| 33 |
+
|
| 34 |
+
```python
|
| 35 |
+
from fastmcp import Client
|
| 36 |
+
from fastmcp.client.elicitation import ElicitResult
|
| 37 |
+
|
| 38 |
+
async def elicitation_handler(message: str, response_type: type, params, context) -> ElicitResult:
|
| 39 |
+
# Present the message to the user and collect input
|
| 40 |
+
user_input = input(f"{message}: ")
|
| 41 |
+
|
| 42 |
+
# Create response using the provided dataclass type
|
| 43 |
+
# FastMCP converted the JSON schema to this Python type for you
|
| 44 |
+
response_data = response_type(value=user_input)
|
| 45 |
+
|
| 46 |
+
return ElicitResult(action="accept", content=response_data)
|
| 47 |
+
|
| 48 |
+
client = Client(
|
| 49 |
+
"my_mcp_server.py",
|
| 50 |
+
elicitation_handler=elicitation_handler,
|
| 51 |
+
)
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
### Handler Parameters
|
| 55 |
+
|
| 56 |
+
The elicitation handler receives four parameters:
|
| 57 |
+
|
| 58 |
+
<Card icon="code" title="Elicitation Handler Parameters">
|
| 59 |
+
<ResponseField name="message" type="str">
|
| 60 |
+
The prompt message to display to the user
|
| 61 |
+
</ResponseField>
|
| 62 |
+
|
| 63 |
+
<ResponseField name="response_type" type="type">
|
| 64 |
+
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.
|
| 65 |
+
</ResponseField>
|
| 66 |
+
|
| 67 |
+
<ResponseField name="params" type="ElicitRequestParams">
|
| 68 |
+
The original MCP elicitation request parameters, including the raw JSON schema in `params.requestedSchema` if you need it
|
| 69 |
+
</ResponseField>
|
| 70 |
+
|
| 71 |
+
<ResponseField name="context" type="RequestContext">
|
| 72 |
+
Request context containing metadata about the elicitation request
|
| 73 |
+
</ResponseField>
|
| 74 |
+
</Card>
|
| 75 |
+
|
| 76 |
+
### Response Actions
|
| 77 |
+
|
| 78 |
+
The handler must return an `ElicitResult` object that includes both an action and (when accepted) the user's input:
|
| 79 |
+
|
| 80 |
+
<Card icon="code" title="ElicitResult Structure">
|
| 81 |
+
<ResponseField name="action" type="Literal['accept', 'decline', 'cancel']">
|
| 82 |
+
How the user responded to the elicitation request
|
| 83 |
+
</ResponseField>
|
| 84 |
+
|
| 85 |
+
<ResponseField name="content" type="dataclass instance | dict | None">
|
| 86 |
+
The user's input data (required for "accept", omitted for "decline"/"cancel")
|
| 87 |
+
</ResponseField>
|
| 88 |
+
</Card>
|
| 89 |
+
|
| 90 |
+
**Action Types:**
|
| 91 |
+
- **`accept`**: User provided valid input - include their data in the `content` field
|
| 92 |
+
- **`decline`**: User chose not to provide the requested information - omit `content`
|
| 93 |
+
- **`cancel`**: User cancelled the entire operation - omit `content`
|
| 94 |
+
|
| 95 |
+
## Basic Example
|
| 96 |
+
|
| 97 |
+
```python
|
| 98 |
+
from fastmcp import Client
|
| 99 |
+
from fastmcp.client.elicitation import ElicitResult
|
| 100 |
+
|
| 101 |
+
async def basic_elicitation_handler(message: str, response_type: type, params, context) -> ElicitResult:
|
| 102 |
+
print(f"Server asks: {message}")
|
| 103 |
+
|
| 104 |
+
# Simple text input for demonstration
|
| 105 |
+
user_response = input("Your response: ")
|
| 106 |
+
|
| 107 |
+
if not user_response:
|
| 108 |
+
return ElicitResult(action="decline")
|
| 109 |
+
|
| 110 |
+
# Use the response_type dataclass to create a properly structured response
|
| 111 |
+
# FastMCP handles the conversion from JSON schema to Python type
|
| 112 |
+
return ElicitResult(action="accept", content=response_type(value=user_response))
|
| 113 |
+
|
| 114 |
+
client = Client(
|
| 115 |
+
"my_mcp_server.py",
|
| 116 |
+
elicitation_handler=basic_elicitation_handler
|
| 117 |
+
)
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
|
docs/docs.json
CHANGED
|
@@ -69,8 +69,18 @@
|
|
| 69 |
"pages": [
|
| 70 |
"servers/tools",
|
| 71 |
"servers/resources",
|
| 72 |
-
"servers/prompts"
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
]
|
| 75 |
},
|
| 76 |
{
|
|
@@ -106,6 +116,7 @@
|
|
| 106 |
"group": "Advanced Features",
|
| 107 |
"icon": "stars",
|
| 108 |
"pages": [
|
|
|
|
| 109 |
"clients/logging",
|
| 110 |
"clients/progress",
|
| 111 |
"clients/sampling",
|
|
|
|
| 69 |
"pages": [
|
| 70 |
"servers/tools",
|
| 71 |
"servers/resources",
|
| 72 |
+
"servers/prompts"
|
| 73 |
+
]
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"group": "Advanced Features",
|
| 77 |
+
"icon": "stars",
|
| 78 |
+
"pages": [
|
| 79 |
+
"servers/context",
|
| 80 |
+
"servers/elicitation",
|
| 81 |
+
"servers/logging",
|
| 82 |
+
"servers/progress",
|
| 83 |
+
"servers/sampling"
|
| 84 |
]
|
| 85 |
},
|
| 86 |
{
|
|
|
|
| 116 |
"group": "Advanced Features",
|
| 117 |
"icon": "stars",
|
| 118 |
"pages": [
|
| 119 |
+
"clients/elicitation",
|
| 120 |
"clients/logging",
|
| 121 |
"clients/progress",
|
| 122 |
"clients/sampling",
|
docs/servers/context.mdx
CHANGED
|
@@ -6,7 +6,7 @@ icon: rectangle-code
|
|
| 6 |
---
|
| 7 |
import { VersionBadge } from '/snippets/version-badge.mdx'
|
| 8 |
|
| 9 |
-
When defining FastMCP [tools](/servers/tools), [resources](/servers/resources), resource templates, or [prompts](/servers/prompts), your functions might need to interact with the underlying MCP session or access server capabilities. FastMCP provides the `Context` object for this purpose.
|
| 10 |
|
| 11 |
## What Is Context?
|
| 12 |
|
|
@@ -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 |
|
|
@@ -36,10 +37,10 @@ To use the context object within any of your functions, simply add a parameter t
|
|
| 36 |
|
| 37 |
#### Tools
|
| 38 |
|
| 39 |
-
```python
|
| 40 |
from fastmcp import FastMCP, Context
|
| 41 |
|
| 42 |
-
mcp = FastMCP(name="
|
| 43 |
|
| 44 |
@mcp.tool
|
| 45 |
async def process_file(file_uri: str, ctx: Context) -> str:
|
|
@@ -52,7 +53,11 @@ async def process_file(file_uri: str, ctx: Context) -> str:
|
|
| 52 |
|
| 53 |
<VersionBadge version="2.2.5" />
|
| 54 |
|
| 55 |
-
```python
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
@mcp.resource("resource://user-data")
|
| 57 |
async def get_user_data(ctx: Context) -> dict:
|
| 58 |
"""Fetch personalized user data based on the request context."""
|
|
@@ -70,7 +75,11 @@ async def get_user_profile(user_id: str, ctx: Context) -> dict:
|
|
| 70 |
|
| 71 |
<VersionBadge version="2.2.5" />
|
| 72 |
|
| 73 |
-
```python
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
@mcp.prompt
|
| 75 |
async def data_analysis_request(dataset: str, ctx: Context) -> str:
|
| 76 |
"""Generate a request to analyze data with contextual information."""
|
|
@@ -88,10 +97,10 @@ While the simplest way to access context is through function parameter injection
|
|
| 88 |
FastMCP provides dependency functions that allow you to retrieve the active context from anywhere within a server request's execution flow:
|
| 89 |
|
| 90 |
```python {2,9}
|
| 91 |
-
from fastmcp import FastMCP
|
| 92 |
from fastmcp.server.dependencies import get_context
|
| 93 |
|
| 94 |
-
mcp = FastMCP(name="
|
| 95 |
|
| 96 |
# Utility function that needs context but doesn't receive it as a parameter
|
| 97 |
async def process_data(data: list[float]) -> dict:
|
|
@@ -113,169 +122,71 @@ async def analyze_dataset(dataset_name: str) -> dict:
|
|
| 113 |
|
| 114 |
## Context Capabilities
|
| 115 |
|
|
|
|
|
|
|
| 116 |
### Logging
|
| 117 |
|
| 118 |
-
Send
|
| 119 |
|
| 120 |
```python
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
await ctx.info(f"Analyzing {len(data)} data points")
|
| 126 |
-
|
| 127 |
-
try:
|
| 128 |
-
result = sum(data) / len(data)
|
| 129 |
-
await ctx.info(f"Analysis complete, average: {result}")
|
| 130 |
-
return {"average": result, "count": len(data)}
|
| 131 |
-
except ZeroDivisionError:
|
| 132 |
-
await ctx.warning("Empty data list provided")
|
| 133 |
-
return {"error": "Empty data list"}
|
| 134 |
-
except Exception as e:
|
| 135 |
-
await ctx.error(f"Analysis failed: {str(e)}")
|
| 136 |
-
raise
|
| 137 |
```
|
| 138 |
|
| 139 |
-
|
|
|
|
| 140 |
|
| 141 |
-
|
| 142 |
-
- **`ctx.info(message: str)`**: General information about execution
|
| 143 |
-
- **`ctx.warning(message: str)`**: Potential issues that didn't prevent execution
|
| 144 |
-
- **`ctx.error(message: str)`**: Errors that occurred during execution
|
| 145 |
-
- **`ctx.log(level: Literal["debug", "info", "warning", "error"], message: str, logger_name: str | None = None)`**: Generic log method supporting custom logger names
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
For long-running operations, notify the client about the progress. This allows clients to display progress indicators and provide a better user experience.
|
| 150 |
|
| 151 |
```python
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
total = len(items)
|
| 156 |
-
results = []
|
| 157 |
-
|
| 158 |
-
for i, item in enumerate(items):
|
| 159 |
-
# Report progress as percentage
|
| 160 |
-
await ctx.report_progress(progress=i, total=total)
|
| 161 |
-
|
| 162 |
-
# Process the item (simulated with a sleep)
|
| 163 |
-
await asyncio.sleep(0.1)
|
| 164 |
-
results.append(item.upper())
|
| 165 |
-
|
| 166 |
-
# Report 100% completion
|
| 167 |
-
await ctx.report_progress(progress=total, total=total)
|
| 168 |
-
|
| 169 |
-
return {"processed": len(results), "results": results}
|
| 170 |
```
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
- **`ctx.report_progress(progress: float, total: float | None = None)`**
|
| 175 |
-
- `progress`: Current progress value (e.g., 24)
|
| 176 |
-
- `total`: Optional total value (e.g., 100). If provided, clients may interpret this as a percentage.
|
| 177 |
|
| 178 |
-
|
| 179 |
|
| 180 |
-
|
| 181 |
|
| 182 |
-
|
| 183 |
|
| 184 |
```python
|
| 185 |
-
|
| 186 |
-
async def summarize_document(document_uri: str, ctx: Context) -> str:
|
| 187 |
-
"""Summarize a document by its resource URI."""
|
| 188 |
-
# Read the document content
|
| 189 |
-
content_list = await ctx.read_resource(document_uri)
|
| 190 |
-
|
| 191 |
-
if not content_list:
|
| 192 |
-
return "Document is empty"
|
| 193 |
-
|
| 194 |
-
document_text = content_list[0].content
|
| 195 |
-
|
| 196 |
-
# Example: Generate a simple summary (length-based)
|
| 197 |
-
words = document_text.split()
|
| 198 |
-
total_words = len(words)
|
| 199 |
-
|
| 200 |
-
await ctx.info(f"Document has {total_words} words")
|
| 201 |
-
|
| 202 |
-
# Return a simple summary
|
| 203 |
-
if total_words > 100:
|
| 204 |
-
summary = " ".join(words[:100]) + "..."
|
| 205 |
-
return f"Summary ({total_words} words total): {summary}"
|
| 206 |
-
else:
|
| 207 |
-
return f"Full document ({total_words} words): {document_text}"
|
| 208 |
```
|
| 209 |
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
- **`ctx.read_resource(uri: str | AnyUrl) -> list[ReadResourceContents]`**
|
| 213 |
-
- `uri`: The resource URI to read
|
| 214 |
-
- Returns a list of resource content parts (usually containing just one item)
|
| 215 |
-
|
| 216 |
-
The returned content is typically accessed via `content_list[0].content` and can be text or binary data depending on the resource.
|
| 217 |
|
| 218 |
-
### LLM Sampling
|
| 219 |
|
| 220 |
-
|
| 221 |
|
| 222 |
-
|
| 223 |
|
| 224 |
```python
|
| 225 |
-
|
| 226 |
-
async def analyze_sentiment(text: str, ctx: Context) -> dict:
|
| 227 |
-
"""Analyze the sentiment of a text using the client's LLM."""
|
| 228 |
-
# Create a sampling prompt asking for sentiment analysis
|
| 229 |
-
prompt = f"Analyze the sentiment of the following text as positive, negative, or neutral. Just output a single word - 'positive', 'negative', or 'neutral'. Text to analyze: {text}"
|
| 230 |
-
|
| 231 |
-
# Send the sampling request to the client's LLM (provide a hint for the model you want to use)
|
| 232 |
-
response = await ctx.sample(prompt, model_preferences="claude-3-sonnet")
|
| 233 |
-
|
| 234 |
-
# Process the LLM's response
|
| 235 |
-
sentiment = response.text.strip().lower()
|
| 236 |
-
|
| 237 |
-
# Map to standard sentiment values
|
| 238 |
-
if "positive" in sentiment:
|
| 239 |
-
sentiment = "positive"
|
| 240 |
-
elif "negative" in sentiment:
|
| 241 |
-
sentiment = "negative"
|
| 242 |
-
else:
|
| 243 |
-
sentiment = "neutral"
|
| 244 |
-
|
| 245 |
-
return {"text": text, "sentiment": sentiment}
|
| 246 |
```
|
| 247 |
|
| 248 |
-
|
| 249 |
|
| 250 |
-
|
| 251 |
-
- `messages`: A string or list of strings/message objects to send to the LLM
|
| 252 |
-
- `system_prompt`: Optional system prompt to guide the LLM's behavior
|
| 253 |
-
- `temperature`: Optional sampling temperature (controls randomness)
|
| 254 |
-
- `max_tokens`: Optional maximum number of tokens to generate (defaults to 512)
|
| 255 |
-
- `model_preferences`: Optional model selection preferences (e.g., a model hint string, list of hints, or a ModelPreferences object)
|
| 256 |
-
- Returns the LLM's response as TextContent or ImageContent
|
| 257 |
|
| 258 |
-
|
| 259 |
|
| 260 |
```python
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
"""Generate a Python code example for a given concept."""
|
| 264 |
-
# Using a system prompt and a user message
|
| 265 |
-
response = await ctx.sample(
|
| 266 |
-
messages=f"Write a simple Python code example demonstrating '{concept}'.",
|
| 267 |
-
system_prompt="You are an expert Python programmer. Provide concise, working code examples without explanations.",
|
| 268 |
-
temperature=0.7,
|
| 269 |
-
max_tokens=300
|
| 270 |
-
)
|
| 271 |
-
|
| 272 |
-
code_example = response.text
|
| 273 |
-
return f"```python\n{code_example}\n```"
|
| 274 |
```
|
| 275 |
|
| 276 |
-
|
|
|
|
|
|
|
| 277 |
|
| 278 |
-
###
|
| 279 |
|
| 280 |
<VersionBadge version="2.9.1" />
|
| 281 |
|
|
@@ -294,7 +205,19 @@ async def custom_tool_management(ctx: Context) -> str:
|
|
| 294 |
|
| 295 |
These methods are primarily used internally by FastMCP's automatic notification system and most users will not need to invoke them directly.
|
| 296 |
|
| 297 |
-
###
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
|
| 299 |
Access metadata about the current request and client.
|
| 300 |
|
|
@@ -314,60 +237,6 @@ async def request_info(ctx: Context) -> dict:
|
|
| 314 |
- **`ctx.client_id -> str | None`**: Get the ID of the client making the request, if provided during initialization
|
| 315 |
- **`ctx.session_id -> str | None`**: Get the MCP session ID for session-based data sharing (HTTP transports only)
|
| 316 |
|
| 317 |
-
### Advanced Access
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
#### FastMCP Server and Sessions
|
| 321 |
-
|
| 322 |
-
```python
|
| 323 |
-
@mcp.tool
|
| 324 |
-
async def advanced_tool(ctx: Context) -> str:
|
| 325 |
-
"""Demonstrate advanced context access."""
|
| 326 |
-
# Access the FastMCP server instance
|
| 327 |
-
server_name = ctx.fastmcp.name
|
| 328 |
-
|
| 329 |
-
# Low-level session access (rarely needed)
|
| 330 |
-
session = ctx.session
|
| 331 |
-
request_context = ctx.request_context
|
| 332 |
-
|
| 333 |
-
return f"Server: {server_name}"
|
| 334 |
-
```
|
| 335 |
-
|
| 336 |
-
#### HTTP Requests
|
| 337 |
-
|
| 338 |
-
<VersionBadge version="2.2.7" />
|
| 339 |
-
|
| 340 |
-
<Warning>
|
| 341 |
-
The `ctx.get_http_request()` method is deprecated and will be removed in a future version.
|
| 342 |
-
Please use the `get_http_request()` dependency function instead.
|
| 343 |
-
See the [HTTP Requests pattern](/patterns/http-requests) for more details.
|
| 344 |
-
</Warning>
|
| 345 |
-
|
| 346 |
-
For web applications, you can access the underlying HTTP request:
|
| 347 |
-
|
| 348 |
-
```python
|
| 349 |
-
@mcp.tool
|
| 350 |
-
async def handle_web_request(ctx: Context) -> dict:
|
| 351 |
-
"""Access HTTP request information from the Starlette request."""
|
| 352 |
-
request = ctx.get_http_request()
|
| 353 |
-
|
| 354 |
-
# Access HTTP headers, query parameters, etc.
|
| 355 |
-
user_agent = request.headers.get("user-agent", "Unknown")
|
| 356 |
-
client_ip = request.client.host if request.client else "Unknown"
|
| 357 |
-
|
| 358 |
-
return {
|
| 359 |
-
"user_agent": user_agent,
|
| 360 |
-
"client_ip": client_ip,
|
| 361 |
-
"path": request.url.path,
|
| 362 |
-
}
|
| 363 |
-
```
|
| 364 |
-
|
| 365 |
-
#### Advanced Properties Reference
|
| 366 |
-
|
| 367 |
-
- **`ctx.fastmcp -> FastMCP`**: Access the server instance the context belongs to
|
| 368 |
-
- **`ctx.session`**: Access the raw `mcp.server.session.ServerSession` object
|
| 369 |
-
- **`ctx.request_context`**: Access the raw `mcp.shared.context.RequestContext` object
|
| 370 |
-
|
| 371 |
<Warning>
|
| 372 |
-
|
| 373 |
-
</Warning>
|
|
|
|
| 6 |
---
|
| 7 |
import { VersionBadge } from '/snippets/version-badge.mdx'
|
| 8 |
|
| 9 |
+
When defining FastMCP [tools](/servers/tools), [resources](/servers/resources), resource templates, or [prompts](/servers/prompts), your functions might need to interact with the underlying MCP session or access advanced server capabilities. FastMCP provides the `Context` object for this purpose.
|
| 10 |
|
| 11 |
## What Is Context?
|
| 12 |
|
|
|
|
| 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 |
|
|
|
|
| 37 |
|
| 38 |
#### Tools
|
| 39 |
|
| 40 |
+
```python {1, 6}
|
| 41 |
from fastmcp import FastMCP, Context
|
| 42 |
|
| 43 |
+
mcp = FastMCP(name="Context Demo")
|
| 44 |
|
| 45 |
@mcp.tool
|
| 46 |
async def process_file(file_uri: str, ctx: Context) -> str:
|
|
|
|
| 53 |
|
| 54 |
<VersionBadge version="2.2.5" />
|
| 55 |
|
| 56 |
+
```python {1, 6, 12}
|
| 57 |
+
from fastmcp import FastMCP, Context
|
| 58 |
+
|
| 59 |
+
mcp = FastMCP(name="Context Demo")
|
| 60 |
+
|
| 61 |
@mcp.resource("resource://user-data")
|
| 62 |
async def get_user_data(ctx: Context) -> dict:
|
| 63 |
"""Fetch personalized user data based on the request context."""
|
|
|
|
| 75 |
|
| 76 |
<VersionBadge version="2.2.5" />
|
| 77 |
|
| 78 |
+
```python {1, 6}
|
| 79 |
+
from fastmcp import FastMCP, Context
|
| 80 |
+
|
| 81 |
+
mcp = FastMCP(name="Context Demo")
|
| 82 |
+
|
| 83 |
@mcp.prompt
|
| 84 |
async def data_analysis_request(dataset: str, ctx: Context) -> str:
|
| 85 |
"""Generate a request to analyze data with contextual information."""
|
|
|
|
| 97 |
FastMCP provides dependency functions that allow you to retrieve the active context from anywhere within a server request's execution flow:
|
| 98 |
|
| 99 |
```python {2,9}
|
| 100 |
+
from fastmcp import FastMCP
|
| 101 |
from fastmcp.server.dependencies import get_context
|
| 102 |
|
| 103 |
+
mcp = FastMCP(name="Dependency Demo")
|
| 104 |
|
| 105 |
# Utility function that needs context but doesn't receive it as a parameter
|
| 106 |
async def process_data(data: list[float]) -> dict:
|
|
|
|
| 122 |
|
| 123 |
## Context Capabilities
|
| 124 |
|
| 125 |
+
FastMCP provides several advanced capabilities through the context object. Each capability has dedicated documentation with comprehensive examples and best practices:
|
| 126 |
+
|
| 127 |
### Logging
|
| 128 |
|
| 129 |
+
Send debug, info, warning, and error messages back to the MCP client for visibility into function execution.
|
| 130 |
|
| 131 |
```python
|
| 132 |
+
await ctx.debug("Starting analysis")
|
| 133 |
+
await ctx.info(f"Processing {len(data)} items")
|
| 134 |
+
await ctx.warning("Deprecated parameter used")
|
| 135 |
+
await ctx.error("Processing failed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
```
|
| 137 |
|
| 138 |
+
See [Server Logging](/servers/logging) for complete documentation and examples.
|
| 139 |
+
### Client Elicitation
|
| 140 |
|
| 141 |
+
<VersionBadge version="2.10.0" />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
+
Request structured input from clients during tool execution, enabling interactive workflows and progressive disclosure. This is a new feature in the 6/18/2025 MCP spec.
|
|
|
|
|
|
|
| 144 |
|
| 145 |
```python
|
| 146 |
+
result = await ctx.elicit("Enter your name:", response_type=str)
|
| 147 |
+
if result.action == "accept":
|
| 148 |
+
name = result.data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
```
|
| 150 |
|
| 151 |
+
See [User Elicitation](/servers/elicitation) for detailed examples and supported response types.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
+
### LLM Sampling
|
| 154 |
|
| 155 |
+
<VersionBadge version="2.0.0" />
|
| 156 |
|
| 157 |
+
Request the client's LLM to generate text based on provided messages, useful for leveraging AI capabilities within your tools.
|
| 158 |
|
| 159 |
```python
|
| 160 |
+
response = await ctx.sample("Analyze this data", temperature=0.7)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
```
|
| 162 |
|
| 163 |
+
See [LLM Sampling](/servers/sampling) for comprehensive usage and advanced techniques.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
|
|
|
| 165 |
|
| 166 |
+
### Progress Reporting
|
| 167 |
|
| 168 |
+
Update clients on the progress of long-running operations, enabling progress indicators and better user experience.
|
| 169 |
|
| 170 |
```python
|
| 171 |
+
await ctx.report_progress(progress=50, total=100) # 50% complete
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
```
|
| 173 |
|
| 174 |
+
See [Progress Reporting](/servers/progress) for detailed patterns and examples.
|
| 175 |
|
| 176 |
+
### Resource Access
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
+
Read data from resources registered with your FastMCP server, allowing access to files, configuration, or dynamic content.
|
| 179 |
|
| 180 |
```python
|
| 181 |
+
content_list = await ctx.read_resource("resource://config")
|
| 182 |
+
content = content_list[0].content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
```
|
| 184 |
|
| 185 |
+
**Method signature:**
|
| 186 |
+
- **`ctx.read_resource(uri: str | AnyUrl) -> list[ReadResourceContents]`**: Returns a list of resource content parts
|
| 187 |
+
|
| 188 |
|
| 189 |
+
### Change Notifications
|
| 190 |
|
| 191 |
<VersionBadge version="2.9.1" />
|
| 192 |
|
|
|
|
| 205 |
|
| 206 |
These methods are primarily used internally by FastMCP's automatic notification system and most users will not need to invoke them directly.
|
| 207 |
|
| 208 |
+
### FastMCP Server
|
| 209 |
+
|
| 210 |
+
To access the underlying FastMCP server instance, you can use the `ctx.fastmcp` property:
|
| 211 |
+
|
| 212 |
+
```python
|
| 213 |
+
@mcp.tool
|
| 214 |
+
async def my_tool(ctx: Context) -> None:
|
| 215 |
+
# Access the FastMCP server instance
|
| 216 |
+
server_name = ctx.fastmcp.name
|
| 217 |
+
...
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
### MCP Request
|
| 221 |
|
| 222 |
Access metadata about the current request and client.
|
| 223 |
|
|
|
|
| 237 |
- **`ctx.client_id -> str | None`**: Get the ID of the client making the request, if provided during initialization
|
| 238 |
- **`ctx.session_id -> str | None`**: Get the MCP session ID for session-based data sharing (HTTP transports only)
|
| 239 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
<Warning>
|
| 241 |
+
The MCP request is part of the low-level MCP SDK and intended for advanced use cases. Most users will not need to use it directly.
|
| 242 |
+
</Warning>
|
docs/servers/elicitation.mdx
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: User Elicitation
|
| 3 |
+
sidebarTitle: Elicitation
|
| 4 |
+
description: Request structured input from users during tool execution through the MCP context.
|
| 5 |
+
icon: message-question
|
| 6 |
+
tag: NEW
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
import { VersionBadge } from '/snippets/version-badge.mdx'
|
| 10 |
+
|
| 11 |
+
<VersionBadge version="2.10.0" />
|
| 12 |
+
|
| 13 |
+
User elicitation allows MCP servers to request structured input from users during tool execution. Instead of requiring all inputs upfront, tools can interactively ask for missing parameters, clarification, or additional context as needed.
|
| 14 |
+
|
| 15 |
+
<Tip>
|
| 16 |
+
Most of the examples in this document assume you have a FastMCP server instance named `mcp` and show how to use the `ctx.elicit` method to request user input from an `@mcp.tool`-decorated function.
|
| 17 |
+
</Tip>
|
| 18 |
+
|
| 19 |
+
## What is Elicitation?
|
| 20 |
+
|
| 21 |
+
Elicitation enables tools to pause execution and request specific information from users. This is particularly useful for:
|
| 22 |
+
|
| 23 |
+
- **Missing parameters**: Ask for required information not provided initially
|
| 24 |
+
- **Clarification requests**: Get user confirmation or choices for ambiguous scenarios
|
| 25 |
+
- **Progressive disclosure**: Collect complex information step-by-step
|
| 26 |
+
- **Dynamic workflows**: Adapt tool behavior based on user responses
|
| 27 |
+
|
| 28 |
+
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?"
|
| 29 |
+
|
| 30 |
+
### Basic Usage
|
| 31 |
+
|
| 32 |
+
Use the `ctx.elicit()` method within any tool function to request user input:
|
| 33 |
+
|
| 34 |
+
```python {14-17}
|
| 35 |
+
from fastmcp import FastMCP, Context
|
| 36 |
+
from dataclasses import dataclass
|
| 37 |
+
|
| 38 |
+
mcp = FastMCP("Elicitation Server")
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class UserInfo:
|
| 42 |
+
name: str
|
| 43 |
+
age: int
|
| 44 |
+
|
| 45 |
+
@mcp.tool
|
| 46 |
+
async def collect_user_info(ctx: Context) -> str:
|
| 47 |
+
"""Collect user information through interactive prompts."""
|
| 48 |
+
result = await ctx.elicit(
|
| 49 |
+
message="Please provide your information",
|
| 50 |
+
response_type=UserInfo
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
if result.action == "accept":
|
| 54 |
+
user = result.data
|
| 55 |
+
return f"Hello {user.name}, you are {user.age} years old"
|
| 56 |
+
elif result.action == "decline":
|
| 57 |
+
return "Information not provided"
|
| 58 |
+
else: # cancel
|
| 59 |
+
return "Operation cancelled"
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
## Method Signature
|
| 63 |
+
|
| 64 |
+
<Card icon="code" title="Context Elicitation Method">
|
| 65 |
+
<ResponseField name="ctx.elicit" type="async method">
|
| 66 |
+
<Expandable title="Parameters">
|
| 67 |
+
<ResponseField name="message" type="str">
|
| 68 |
+
The prompt message to display to the user
|
| 69 |
+
</ResponseField>
|
| 70 |
+
|
| 71 |
+
<ResponseField name="response_type" type="type" default="str">
|
| 72 |
+
The Python type defining the expected response structure (dataclass, primitive type, etc.) Note that elicitation responses are subject to a restricted subset of JSON Schema types. See [Supported Response Types](#supported-response-types) for more details.
|
| 73 |
+
</ResponseField>
|
| 74 |
+
</Expandable>
|
| 75 |
+
|
| 76 |
+
<Expandable title="Response">
|
| 77 |
+
<ResponseField name="ElicitationResult" type="object">
|
| 78 |
+
Result object containing the user's response
|
| 79 |
+
|
| 80 |
+
<Expandable title="properties">
|
| 81 |
+
<ResponseField name="action" type="Literal['accept', 'decline', 'cancel']">
|
| 82 |
+
How the user responded to the request
|
| 83 |
+
</ResponseField>
|
| 84 |
+
|
| 85 |
+
<ResponseField name="data" type="response_type | None">
|
| 86 |
+
The user's input data (only present when action is "accept")
|
| 87 |
+
</ResponseField>
|
| 88 |
+
</Expandable>
|
| 89 |
+
</ResponseField>
|
| 90 |
+
</Expandable>
|
| 91 |
+
</ResponseField>
|
| 92 |
+
</Card>
|
| 93 |
+
|
| 94 |
+
## Elicitation Actions
|
| 95 |
+
|
| 96 |
+
The elicitation result contains an `action` field indicating how the user responded:
|
| 97 |
+
|
| 98 |
+
- **`accept`**: User provided valid input - data is available in the `data` field
|
| 99 |
+
- **`decline`**: User chose not to provide the requested information and the data field is `None`
|
| 100 |
+
- **`cancel`**: User cancelled the entire operation and the data field is `None`
|
| 101 |
+
|
| 102 |
+
```python {5, 7}
|
| 103 |
+
@mcp.tool
|
| 104 |
+
async def my_tool(ctx: Context) -> str:
|
| 105 |
+
result = await ctx.elicit("Choose an action")
|
| 106 |
+
|
| 107 |
+
if result.action == "accept":
|
| 108 |
+
return "Accepted!"
|
| 109 |
+
elif result.action == "decline":
|
| 110 |
+
return "Declined!"
|
| 111 |
+
else:
|
| 112 |
+
return "Cancelled!"
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
FastMCP also provides typed result classes for pattern matching on the `action` field:
|
| 116 |
+
|
| 117 |
+
```python {1-5, 12, 14, 16}
|
| 118 |
+
from fastmcp.server.elicitation import (
|
| 119 |
+
AcceptedElicitation,
|
| 120 |
+
DeclinedElicitation,
|
| 121 |
+
CancelledElicitation,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
@mcp.tool
|
| 125 |
+
async def pattern_example(ctx: Context) -> str:
|
| 126 |
+
result = await ctx.elicit("Enter your name:", response_type=str)
|
| 127 |
+
|
| 128 |
+
match result:
|
| 129 |
+
case AcceptedElicitation(data=name):
|
| 130 |
+
return f"Hello {name}!"
|
| 131 |
+
case DeclinedElicitation():
|
| 132 |
+
return "No name provided"
|
| 133 |
+
case CancelledElicitation():
|
| 134 |
+
return "Operation cancelled"
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## Response Types
|
| 138 |
+
|
| 139 |
+
The server must send a schema to the client indicating the type of data it expects in response to the elicitation request. If the request is `accept`-ed, the client must send a response that matches the schema.
|
| 140 |
+
|
| 141 |
+
The MCP spec only supports a limited subset of JSON Schema types for elicitation responses. Specifically, it only supports JSON **objects** with **primitive** properties including `string`, `number` (or `integer`), `boolean` and `enum` fields.
|
| 142 |
+
|
| 143 |
+
FastMCP makes it easy to request a broader range of types, including scalars (e.g. `str`), by automatically wrapping them in MCP-compatible object schemas.
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
### Scalar Types
|
| 147 |
+
|
| 148 |
+
You can request simple scalar data types for basic input, such as a string, integer, or boolean.
|
| 149 |
+
|
| 150 |
+
When you request a scalar type, FastMCP automatically wraps it in an object schema for MCP spec compatibility. Clients will see a corresponding schema requesting a single "value" field of the requested type. Once clients respond, the provided object is "unwrapped" and the scalar value is returned to your tool function as the `data` field of the `ElicitationResult` object.
|
| 151 |
+
|
| 152 |
+
As a developer, this means you do not have to worry about creating or accessing a structured object when you only need a scalar value.
|
| 153 |
+
|
| 154 |
+
<CodeGroup>
|
| 155 |
+
```python {4} title="Request a string"
|
| 156 |
+
@mcp.tool
|
| 157 |
+
async def get_user_name(ctx: Context) -> str:
|
| 158 |
+
"""Get the user's name."""
|
| 159 |
+
result = await ctx.elicit("What's your name?", response_type=str)
|
| 160 |
+
|
| 161 |
+
if result.action == "accept":
|
| 162 |
+
return f"Hello, {result.data}!"
|
| 163 |
+
return "No name provided"
|
| 164 |
+
```
|
| 165 |
+
```python {4} title="Request an integer"
|
| 166 |
+
@mcp.tool
|
| 167 |
+
async def pick_a_number(ctx: Context) -> str:
|
| 168 |
+
"""Pick a number."""
|
| 169 |
+
result = await ctx.elicit("Pick a number!", response_type=int)
|
| 170 |
+
|
| 171 |
+
if result.action == "accept":
|
| 172 |
+
return f"You picked {result.data}"
|
| 173 |
+
return "No number provided"
|
| 174 |
+
```
|
| 175 |
+
```python {4} title="Request a boolean"
|
| 176 |
+
@mcp.tool
|
| 177 |
+
async def pick_a_boolean(ctx: Context) -> str:
|
| 178 |
+
"""Pick a boolean."""
|
| 179 |
+
result = await ctx.elicit("True or false?", response_type=bool)
|
| 180 |
+
|
| 181 |
+
if result.action == "accept":
|
| 182 |
+
return f"You picked {result.data}"
|
| 183 |
+
return "No boolean provided"
|
| 184 |
+
```
|
| 185 |
+
</CodeGroup>
|
| 186 |
+
|
| 187 |
+
### Constrained Options
|
| 188 |
+
|
| 189 |
+
Often you'll want to constrain the user's response to a specific set of values. You can do this by using a `Literal` type or a Python enum as the response type, or by passing a list of strings to the `response_type` parameter as a convenient shortcut.
|
| 190 |
+
|
| 191 |
+
<CodeGroup>
|
| 192 |
+
```python {6} title="Using a list of strings"
|
| 193 |
+
@mcp.tool
|
| 194 |
+
async def set_priority(ctx: Context) -> str:
|
| 195 |
+
"""Set task priority level."""
|
| 196 |
+
result = await ctx.elicit(
|
| 197 |
+
"What priority level?",
|
| 198 |
+
response_type=["low", "medium", "high"],
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
if result.action == "accept":
|
| 202 |
+
return f"Priority set to: {result.data}"
|
| 203 |
+
```
|
| 204 |
+
```python {1, 8} title="Using a Literal type"
|
| 205 |
+
from typing import Literal
|
| 206 |
+
|
| 207 |
+
@mcp.tool
|
| 208 |
+
async def set_priority(ctx: Context) -> str:
|
| 209 |
+
"""Set task priority level."""
|
| 210 |
+
result = await ctx.elicit(
|
| 211 |
+
"What priority level?",
|
| 212 |
+
response_type=Literal["low", "medium", "high"]
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
if result.action == "accept":
|
| 216 |
+
return f"Priority set to: {result.data}"
|
| 217 |
+
return "No priority set"
|
| 218 |
+
```
|
| 219 |
+
```python {1, 11} title="Using a Python enum"
|
| 220 |
+
from enum import Enum
|
| 221 |
+
|
| 222 |
+
class Priority(Enum):
|
| 223 |
+
LOW = "low"
|
| 224 |
+
MEDIUM = "medium"
|
| 225 |
+
HIGH = "high"
|
| 226 |
+
|
| 227 |
+
@mcp.tool
|
| 228 |
+
async def set_priority(ctx: Context) -> str:
|
| 229 |
+
"""Set task priority level."""
|
| 230 |
+
result = await ctx.elicit("What priority level?", response_type=Priority)
|
| 231 |
+
|
| 232 |
+
if result.action == "accept":
|
| 233 |
+
return f"Priority set to: {result.data.value}"
|
| 234 |
+
return "No priority set"
|
| 235 |
+
```
|
| 236 |
+
</CodeGroup>
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
### Structured Responses
|
| 240 |
+
|
| 241 |
+
You can request structured data with multiple fields by using a dataclass, typed dict, or Pydantic model as the response type. Note that the MCP spec only supports shallow objects with scalar (string, number, boolean) or enum properties.
|
| 242 |
+
|
| 243 |
+
```python {1, 16, 20}
|
| 244 |
+
from dataclasses import dataclass
|
| 245 |
+
from typing import Literal
|
| 246 |
+
|
| 247 |
+
@dataclass
|
| 248 |
+
class TaskDetails:
|
| 249 |
+
title: str
|
| 250 |
+
description: str
|
| 251 |
+
priority: Literal["low", "medium", "high"]
|
| 252 |
+
due_date: str
|
| 253 |
+
|
| 254 |
+
@mcp.tool
|
| 255 |
+
async def create_task(ctx: Context) -> str:
|
| 256 |
+
"""Create a new task with user-provided details."""
|
| 257 |
+
result = await ctx.elicit(
|
| 258 |
+
"Please provide task details",
|
| 259 |
+
response_type=TaskDetails
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
if result.action == "accept":
|
| 263 |
+
task = result.data
|
| 264 |
+
return f"Created task: {task.title} (Priority: {task.priority})"
|
| 265 |
+
return "Task creation cancelled"
|
| 266 |
+
```
|
| 267 |
+
|
| 268 |
+
## Multi-Turn Elicitation
|
| 269 |
+
|
| 270 |
+
Tools can make multiple elicitation calls to gather information progressively:
|
| 271 |
+
|
| 272 |
+
```python {6, 11, 16-19}
|
| 273 |
+
@mcp.tool
|
| 274 |
+
async def plan_meeting(ctx: Context) -> str:
|
| 275 |
+
"""Plan a meeting by gathering details step by step."""
|
| 276 |
+
|
| 277 |
+
# Get meeting title
|
| 278 |
+
title_result = await ctx.elicit("What's the meeting title?", response_type=str)
|
| 279 |
+
if title_result.action != "accept":
|
| 280 |
+
return "Meeting planning cancelled"
|
| 281 |
+
|
| 282 |
+
# Get duration
|
| 283 |
+
duration_result = await ctx.elicit("Duration in minutes?", response_type=int)
|
| 284 |
+
if duration_result.action != "accept":
|
| 285 |
+
return "Meeting planning cancelled"
|
| 286 |
+
|
| 287 |
+
# Get priority
|
| 288 |
+
priority_result = await ctx.elicit(
|
| 289 |
+
"Is this urgent?",
|
| 290 |
+
response_type=Literal["yes", "no"]
|
| 291 |
+
)
|
| 292 |
+
if priority_result.action != "accept":
|
| 293 |
+
return "Meeting planning cancelled"
|
| 294 |
+
|
| 295 |
+
urgent = priority_result.data == "yes"
|
| 296 |
+
return f"Meeting '{title_result.data}' planned for {duration_result.data} minutes (Urgent: {urgent})"
|
| 297 |
+
```
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
## Client Requirements
|
| 301 |
+
|
| 302 |
+
Elicitation requires the client to implement an elicitation handler. See [Client Elicitation](/clients/elicitation) for details on how clients can handle these requests.
|
| 303 |
+
|
| 304 |
+
If a client doesn't support elicitation, calls to `ctx.elicit()` will raise an error indicating that elicitation is not supported.
|
docs/servers/logging.mdx
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Server Logging
|
| 3 |
+
sidebarTitle: Logging
|
| 4 |
+
description: Send log messages back to MCP clients through the context.
|
| 5 |
+
icon: receipt
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
import { VersionBadge } from '/snippets/version-badge.mdx'
|
| 9 |
+
|
| 10 |
+
<Tip>
|
| 11 |
+
This documentation covers **MCP client logging** - sending messages from your server to MCP clients. For standard server-side logging (e.g., writing to files, console), use `fastmcp.utilities.logging.get_logger()` or Python's built-in `logging` module.
|
| 12 |
+
</Tip>
|
| 13 |
+
|
| 14 |
+
Server logging allows MCP tools to send debug, info, warning, and error messages back to the client. This provides visibility into function execution and helps with debugging during development and operation.
|
| 15 |
+
|
| 16 |
+
## Why Use Server Logging?
|
| 17 |
+
|
| 18 |
+
Server logging is essential for:
|
| 19 |
+
|
| 20 |
+
- **Debugging**: Send detailed execution information to help diagnose issues
|
| 21 |
+
- **Progress visibility**: Keep users informed about what the tool is doing
|
| 22 |
+
- **Error reporting**: Communicate problems and their context to clients
|
| 23 |
+
- **Audit trails**: Create records of tool execution for compliance or analysis
|
| 24 |
+
|
| 25 |
+
Unlike standard Python logging, MCP server logging sends messages directly to the client, making them visible in the client's interface or logs.
|
| 26 |
+
|
| 27 |
+
### Basic Usage
|
| 28 |
+
|
| 29 |
+
Use the context logging methods within any tool function:
|
| 30 |
+
|
| 31 |
+
```python {8-9, 13, 17, 21}
|
| 32 |
+
from fastmcp import FastMCP, Context
|
| 33 |
+
|
| 34 |
+
mcp = FastMCP("LoggingDemo")
|
| 35 |
+
|
| 36 |
+
@mcp.tool
|
| 37 |
+
async def analyze_data(data: list[float], ctx: Context) -> dict:
|
| 38 |
+
"""Analyze numerical data with comprehensive logging."""
|
| 39 |
+
await ctx.debug("Starting analysis of numerical data")
|
| 40 |
+
await ctx.info(f"Analyzing {len(data)} data points")
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
if not data:
|
| 44 |
+
await ctx.warning("Empty data list provided")
|
| 45 |
+
return {"error": "Empty data list"}
|
| 46 |
+
|
| 47 |
+
result = sum(data) / len(data)
|
| 48 |
+
await ctx.info(f"Analysis complete, average: {result}")
|
| 49 |
+
return {"average": result, "count": len(data)}
|
| 50 |
+
|
| 51 |
+
except Exception as e:
|
| 52 |
+
await ctx.error(f"Analysis failed: {str(e)}")
|
| 53 |
+
raise
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## Logging Methods
|
| 57 |
+
|
| 58 |
+
<Card icon="code" title="Context Logging Methods">
|
| 59 |
+
<ResponseField name="ctx.debug" type="async method">
|
| 60 |
+
Send debug-level messages for detailed execution information
|
| 61 |
+
|
| 62 |
+
<Expandable title="parameters">
|
| 63 |
+
<ResponseField name="message" type="str">
|
| 64 |
+
The debug message to send to the client
|
| 65 |
+
</ResponseField>
|
| 66 |
+
</Expandable>
|
| 67 |
+
</ResponseField>
|
| 68 |
+
|
| 69 |
+
<ResponseField name="ctx.info" type="async method">
|
| 70 |
+
Send informational messages about normal execution
|
| 71 |
+
|
| 72 |
+
<Expandable title="parameters">
|
| 73 |
+
<ResponseField name="message" type="str">
|
| 74 |
+
The information message to send to the client
|
| 75 |
+
</ResponseField>
|
| 76 |
+
</Expandable>
|
| 77 |
+
</ResponseField>
|
| 78 |
+
|
| 79 |
+
<ResponseField name="ctx.warning" type="async method">
|
| 80 |
+
Send warning messages for potential issues that didn't prevent execution
|
| 81 |
+
|
| 82 |
+
<Expandable title="parameters">
|
| 83 |
+
<ResponseField name="message" type="str">
|
| 84 |
+
The warning message to send to the client
|
| 85 |
+
</ResponseField>
|
| 86 |
+
</Expandable>
|
| 87 |
+
</ResponseField>
|
| 88 |
+
|
| 89 |
+
<ResponseField name="ctx.error" type="async method">
|
| 90 |
+
Send error messages for problems that occurred during execution
|
| 91 |
+
|
| 92 |
+
<Expandable title="parameters">
|
| 93 |
+
<ResponseField name="message" type="str">
|
| 94 |
+
The error message to send to the client
|
| 95 |
+
</ResponseField>
|
| 96 |
+
</Expandable>
|
| 97 |
+
</ResponseField>
|
| 98 |
+
|
| 99 |
+
<ResponseField name="ctx.log" type="async method">
|
| 100 |
+
Generic logging method with custom level and logger name
|
| 101 |
+
|
| 102 |
+
<Expandable title="parameters">
|
| 103 |
+
<ResponseField name="level" type="Literal['debug', 'info', 'warning', 'error']">
|
| 104 |
+
The log level for the message
|
| 105 |
+
</ResponseField>
|
| 106 |
+
|
| 107 |
+
<ResponseField name="message" type="str">
|
| 108 |
+
The message to send to the client
|
| 109 |
+
</ResponseField>
|
| 110 |
+
|
| 111 |
+
<ResponseField name="logger_name" type="str | None" default="None">
|
| 112 |
+
Optional custom logger name for categorizing messages
|
| 113 |
+
</ResponseField>
|
| 114 |
+
</Expandable>
|
| 115 |
+
</ResponseField>
|
| 116 |
+
</Card>
|
| 117 |
+
|
| 118 |
+
## Log Levels
|
| 119 |
+
|
| 120 |
+
### Debug
|
| 121 |
+
Use for detailed information that's typically only useful when diagnosing problems:
|
| 122 |
+
|
| 123 |
+
```python
|
| 124 |
+
@mcp.tool
|
| 125 |
+
async def process_file(file_path: str, ctx: Context) -> str:
|
| 126 |
+
"""Process a file with detailed debug logging."""
|
| 127 |
+
await ctx.debug(f"Starting to process file: {file_path}")
|
| 128 |
+
await ctx.debug("Checking file permissions")
|
| 129 |
+
|
| 130 |
+
# File processing logic
|
| 131 |
+
await ctx.debug("File processing completed successfully")
|
| 132 |
+
return "File processed"
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
### Info
|
| 136 |
+
Use for general information about normal program execution:
|
| 137 |
+
|
| 138 |
+
```python
|
| 139 |
+
@mcp.tool
|
| 140 |
+
async def backup_database(ctx: Context) -> str:
|
| 141 |
+
"""Backup database with progress information."""
|
| 142 |
+
await ctx.info("Starting database backup")
|
| 143 |
+
await ctx.info("Connecting to database")
|
| 144 |
+
await ctx.info("Backup completed successfully")
|
| 145 |
+
return "Database backed up"
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
### Warning
|
| 149 |
+
Use for potentially harmful situations that don't prevent execution:
|
| 150 |
+
|
| 151 |
+
```python
|
| 152 |
+
@mcp.tool
|
| 153 |
+
async def validate_config(config: dict, ctx: Context) -> dict:
|
| 154 |
+
"""Validate configuration with warnings for deprecated options."""
|
| 155 |
+
if "old_api_key" in config:
|
| 156 |
+
await ctx.warning("Using deprecated 'old_api_key' field. Please use 'api_key' instead")
|
| 157 |
+
|
| 158 |
+
if config.get("timeout", 30) > 300:
|
| 159 |
+
await ctx.warning("Timeout value is very high (>5 minutes), this may cause issues")
|
| 160 |
+
|
| 161 |
+
return {"status": "valid", "warnings": "see logs"}
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
### Error
|
| 165 |
+
Use for error events that might still allow the application to continue:
|
| 166 |
+
|
| 167 |
+
```python
|
| 168 |
+
@mcp.tool
|
| 169 |
+
async def batch_process(items: list[str], ctx: Context) -> dict:
|
| 170 |
+
"""Process multiple items, logging errors for failed items."""
|
| 171 |
+
successful = 0
|
| 172 |
+
failed = 0
|
| 173 |
+
|
| 174 |
+
for item in items:
|
| 175 |
+
try:
|
| 176 |
+
# Process item
|
| 177 |
+
successful += 1
|
| 178 |
+
except Exception as e:
|
| 179 |
+
await ctx.error(f"Failed to process item '{item}': {str(e)}")
|
| 180 |
+
failed += 1
|
| 181 |
+
|
| 182 |
+
return {"successful": successful, "failed": failed}
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
## Client Handling
|
| 187 |
+
|
| 188 |
+
Log messages are sent to the client through the MCP protocol. How clients handle these messages depends on their implementation:
|
| 189 |
+
|
| 190 |
+
- **Development clients**: May display logs in real-time for debugging
|
| 191 |
+
- **Production clients**: May store logs for later analysis or display to users
|
| 192 |
+
- **Integration clients**: May forward logs to external logging systems
|
| 193 |
+
|
| 194 |
+
See [Client Logging](/clients/logging) for details on how clients can handle server log messages.
|
docs/servers/progress.mdx
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Progress Reporting
|
| 3 |
+
sidebarTitle: Progress
|
| 4 |
+
description: Update clients on the progress of long-running operations through the MCP context.
|
| 5 |
+
icon: chart-line
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
import { VersionBadge } from '/snippets/version-badge.mdx'
|
| 9 |
+
|
| 10 |
+
Progress reporting allows MCP tools to notify clients about the progress of long-running operations. This enables clients to display progress indicators and provide better user experience during time-consuming tasks.
|
| 11 |
+
|
| 12 |
+
## Why Use Progress Reporting?
|
| 13 |
+
|
| 14 |
+
Progress reporting is valuable for:
|
| 15 |
+
|
| 16 |
+
- **User experience**: Keep users informed about long-running operations
|
| 17 |
+
- **Progress indicators**: Enable clients to show progress bars or percentages
|
| 18 |
+
- **Timeout prevention**: Demonstrate that operations are actively progressing
|
| 19 |
+
- **Debugging**: Track execution progress for performance analysis
|
| 20 |
+
|
| 21 |
+
### Basic Usage
|
| 22 |
+
|
| 23 |
+
Use `ctx.report_progress()` to send progress updates to the client:
|
| 24 |
+
|
| 25 |
+
```python {14, 21}
|
| 26 |
+
from fastmcp import FastMCP, Context
|
| 27 |
+
import asyncio
|
| 28 |
+
|
| 29 |
+
mcp = FastMCP("ProgressDemo")
|
| 30 |
+
|
| 31 |
+
@mcp.tool
|
| 32 |
+
async def process_items(items: list[str], ctx: Context) -> dict:
|
| 33 |
+
"""Process a list of items with progress updates."""
|
| 34 |
+
total = len(items)
|
| 35 |
+
results = []
|
| 36 |
+
|
| 37 |
+
for i, item in enumerate(items):
|
| 38 |
+
# Report progress as we process each item
|
| 39 |
+
await ctx.report_progress(progress=i, total=total)
|
| 40 |
+
|
| 41 |
+
# Simulate processing time
|
| 42 |
+
await asyncio.sleep(0.1)
|
| 43 |
+
results.append(item.upper())
|
| 44 |
+
|
| 45 |
+
# Report 100% completion
|
| 46 |
+
await ctx.report_progress(progress=total, total=total)
|
| 47 |
+
|
| 48 |
+
return {"processed": len(results), "results": results}
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
## Method Signature
|
| 52 |
+
|
| 53 |
+
<Card icon="code" title="Context Progress Method">
|
| 54 |
+
<ResponseField name="ctx.report_progress" type="async method">
|
| 55 |
+
Report progress to the client for long-running operations
|
| 56 |
+
|
| 57 |
+
<Expandable title="Parameters">
|
| 58 |
+
<ResponseField name="progress" type="float">
|
| 59 |
+
Current progress value (e.g., 24, 0.75, 1500)
|
| 60 |
+
</ResponseField>
|
| 61 |
+
|
| 62 |
+
<ResponseField name="total" type="float | None" default="None">
|
| 63 |
+
Optional total value (e.g., 100, 1.0, 2000). When provided, clients may interpret this as enabling percentage calculation.
|
| 64 |
+
</ResponseField>
|
| 65 |
+
</Expandable>
|
| 66 |
+
</ResponseField>
|
| 67 |
+
</Card>
|
| 68 |
+
|
| 69 |
+
## Progress Patterns
|
| 70 |
+
|
| 71 |
+
### Percentage-Based Progress
|
| 72 |
+
|
| 73 |
+
Report progress as a percentage (0-100):
|
| 74 |
+
|
| 75 |
+
```python {13-14}
|
| 76 |
+
@mcp.tool
|
| 77 |
+
async def download_file(url: str, ctx: Context) -> str:
|
| 78 |
+
"""Download a file with percentage progress."""
|
| 79 |
+
total_size = 1000 # KB
|
| 80 |
+
downloaded = 0
|
| 81 |
+
|
| 82 |
+
while downloaded < total_size:
|
| 83 |
+
# Download chunk
|
| 84 |
+
chunk_size = min(50, total_size - downloaded)
|
| 85 |
+
downloaded += chunk_size
|
| 86 |
+
|
| 87 |
+
# Report percentage progress
|
| 88 |
+
percentage = (downloaded / total_size) * 100
|
| 89 |
+
await ctx.report_progress(progress=percentage, total=100)
|
| 90 |
+
|
| 91 |
+
await asyncio.sleep(0.1) # Simulate download time
|
| 92 |
+
|
| 93 |
+
return f"Downloaded file from {url}"
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
### Absolute Progress
|
| 97 |
+
|
| 98 |
+
Report progress with absolute values:
|
| 99 |
+
|
| 100 |
+
```python {10}
|
| 101 |
+
@mcp.tool
|
| 102 |
+
async def backup_database(ctx: Context) -> str:
|
| 103 |
+
"""Backup database tables with absolute progress."""
|
| 104 |
+
tables = ["users", "orders", "products", "inventory", "logs"]
|
| 105 |
+
|
| 106 |
+
for i, table in enumerate(tables):
|
| 107 |
+
await ctx.info(f"Backing up table: {table}")
|
| 108 |
+
|
| 109 |
+
# Report absolute progress
|
| 110 |
+
await ctx.report_progress(progress=i + 1, total=len(tables))
|
| 111 |
+
|
| 112 |
+
# Simulate backup time
|
| 113 |
+
await asyncio.sleep(0.5)
|
| 114 |
+
|
| 115 |
+
return "Database backup completed"
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### Indeterminate Progress
|
| 119 |
+
|
| 120 |
+
Report progress without a known total for operations where the endpoint is unknown:
|
| 121 |
+
|
| 122 |
+
```python {11}
|
| 123 |
+
@mcp.tool
|
| 124 |
+
async def scan_directory(directory: str, ctx: Context) -> dict:
|
| 125 |
+
"""Scan directory with indeterminate progress."""
|
| 126 |
+
files_found = 0
|
| 127 |
+
|
| 128 |
+
# Simulate directory scanning
|
| 129 |
+
for i in range(10): # Unknown number of files
|
| 130 |
+
files_found += 1
|
| 131 |
+
|
| 132 |
+
# Report progress without total for indeterminate operations
|
| 133 |
+
await ctx.report_progress(progress=files_found)
|
| 134 |
+
|
| 135 |
+
await asyncio.sleep(0.2)
|
| 136 |
+
|
| 137 |
+
return {"files_found": files_found, "directory": directory}
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
### Multi-Stage Operations
|
| 141 |
+
|
| 142 |
+
Break complex operations into stages with progress for each:
|
| 143 |
+
|
| 144 |
+
```python
|
| 145 |
+
@mcp.tool
|
| 146 |
+
async def data_migration(source: str, destination: str, ctx: Context) -> str:
|
| 147 |
+
"""Migrate data with multi-stage progress reporting."""
|
| 148 |
+
|
| 149 |
+
# Stage 1: Validation (0-25%)
|
| 150 |
+
await ctx.info("Validating source data")
|
| 151 |
+
for i in range(5):
|
| 152 |
+
await ctx.report_progress(progress=i * 5, total=100)
|
| 153 |
+
await asyncio.sleep(0.1)
|
| 154 |
+
|
| 155 |
+
# Stage 2: Export (25-60%)
|
| 156 |
+
await ctx.info("Exporting data from source")
|
| 157 |
+
for i in range(7):
|
| 158 |
+
progress = 25 + (i * 5)
|
| 159 |
+
await ctx.report_progress(progress=progress, total=100)
|
| 160 |
+
await asyncio.sleep(0.1)
|
| 161 |
+
|
| 162 |
+
# Stage 3: Transform (60-80%)
|
| 163 |
+
await ctx.info("Transforming data format")
|
| 164 |
+
for i in range(4):
|
| 165 |
+
progress = 60 + (i * 5)
|
| 166 |
+
await ctx.report_progress(progress=progress, total=100)
|
| 167 |
+
await asyncio.sleep(0.1)
|
| 168 |
+
|
| 169 |
+
# Stage 4: Import (80-100%)
|
| 170 |
+
await ctx.info("Importing to destination")
|
| 171 |
+
for i in range(4):
|
| 172 |
+
progress = 80 + (i * 5)
|
| 173 |
+
await ctx.report_progress(progress=progress, total=100)
|
| 174 |
+
await asyncio.sleep(0.1)
|
| 175 |
+
|
| 176 |
+
# Final completion
|
| 177 |
+
await ctx.report_progress(progress=100, total=100)
|
| 178 |
+
|
| 179 |
+
return f"Migration from {source} to {destination} completed"
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
## Client Requirements
|
| 184 |
+
|
| 185 |
+
Progress reporting requires clients to support progress handling:
|
| 186 |
+
|
| 187 |
+
- Clients must send a `progressToken` in the initial request to receive progress updates
|
| 188 |
+
- If no progress token is provided, progress calls will have no effect (they won't error)
|
| 189 |
+
- See [Client Progress](/clients/progress) for details on implementing client-side progress handling
|
docs/servers/sampling.mdx
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: LLM Sampling
|
| 3 |
+
sidebarTitle: Sampling
|
| 4 |
+
description: Request the client's LLM to generate text based on provided messages through the MCP context.
|
| 5 |
+
icon: robot
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
import { VersionBadge } from '/snippets/version-badge.mdx'
|
| 9 |
+
|
| 10 |
+
<VersionBadge version="2.0.0" />
|
| 11 |
+
|
| 12 |
+
LLM sampling allows MCP tools to request the client's LLM to generate text based on provided messages. This is useful when tools need to leverage the LLM's capabilities to process data, generate responses, or perform text-based analysis.
|
| 13 |
+
|
| 14 |
+
## Why Use LLM Sampling?
|
| 15 |
+
|
| 16 |
+
LLM sampling enables tools to:
|
| 17 |
+
|
| 18 |
+
- **Leverage AI capabilities**: Use the client's LLM for text generation and analysis
|
| 19 |
+
- **Offload complex reasoning**: Let the LLM handle tasks requiring natural language understanding
|
| 20 |
+
- **Generate dynamic content**: Create responses, summaries, or transformations based on data
|
| 21 |
+
- **Maintain context**: Use the same LLM instance that the user is already interacting with
|
| 22 |
+
|
| 23 |
+
### Basic Usage
|
| 24 |
+
|
| 25 |
+
Use `ctx.sample()` to request text generation from the client's LLM:
|
| 26 |
+
|
| 27 |
+
```python {14}
|
| 28 |
+
from fastmcp import FastMCP, Context
|
| 29 |
+
|
| 30 |
+
mcp = FastMCP("SamplingDemo")
|
| 31 |
+
|
| 32 |
+
@mcp.tool
|
| 33 |
+
async def analyze_sentiment(text: str, ctx: Context) -> dict:
|
| 34 |
+
"""Analyze the sentiment of text using the client's LLM."""
|
| 35 |
+
prompt = f"""Analyze the sentiment of the following text as positive, negative, or neutral.
|
| 36 |
+
Just output a single word - 'positive', 'negative', or 'neutral'.
|
| 37 |
+
|
| 38 |
+
Text to analyze: {text}"""
|
| 39 |
+
|
| 40 |
+
# Request LLM analysis
|
| 41 |
+
response = await ctx.sample(prompt)
|
| 42 |
+
|
| 43 |
+
# Process the LLM's response
|
| 44 |
+
sentiment = response.text.strip().lower()
|
| 45 |
+
|
| 46 |
+
# Map to standard sentiment values
|
| 47 |
+
if "positive" in sentiment:
|
| 48 |
+
sentiment = "positive"
|
| 49 |
+
elif "negative" in sentiment:
|
| 50 |
+
sentiment = "negative"
|
| 51 |
+
else:
|
| 52 |
+
sentiment = "neutral"
|
| 53 |
+
|
| 54 |
+
return {"text": text, "sentiment": sentiment}
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
## Method Signature
|
| 58 |
+
|
| 59 |
+
<Card icon="code" title="Context Sampling Method">
|
| 60 |
+
<ResponseField name="ctx.sample" type="async method">
|
| 61 |
+
Request text generation from the client's LLM
|
| 62 |
+
|
| 63 |
+
<Expandable title="Parameters">
|
| 64 |
+
<ResponseField name="messages" type="str | list[str | SamplingMessage]">
|
| 65 |
+
A string or list of strings/message objects to send to the LLM
|
| 66 |
+
</ResponseField>
|
| 67 |
+
|
| 68 |
+
<ResponseField name="system_prompt" type="str | None" default="None">
|
| 69 |
+
Optional system prompt to guide the LLM's behavior
|
| 70 |
+
</ResponseField>
|
| 71 |
+
|
| 72 |
+
<ResponseField name="temperature" type="float | None" default="None">
|
| 73 |
+
Optional sampling temperature (controls randomness, typically 0.0-1.0)
|
| 74 |
+
</ResponseField>
|
| 75 |
+
|
| 76 |
+
<ResponseField name="max_tokens" type="int | None" default="512">
|
| 77 |
+
Optional maximum number of tokens to generate
|
| 78 |
+
</ResponseField>
|
| 79 |
+
|
| 80 |
+
<ResponseField name="model_preferences" type="ModelPreferences | str | list[str] | None" default="None">
|
| 81 |
+
Optional model selection preferences (e.g., model hint string, list of hints, or ModelPreferences object)
|
| 82 |
+
</ResponseField>
|
| 83 |
+
</Expandable>
|
| 84 |
+
|
| 85 |
+
<Expandable title="Response">
|
| 86 |
+
<ResponseField name="response" type="TextContent | ImageContent">
|
| 87 |
+
The LLM's response content (typically TextContent with a .text attribute)
|
| 88 |
+
</ResponseField>
|
| 89 |
+
</Expandable>
|
| 90 |
+
</ResponseField>
|
| 91 |
+
</Card>
|
| 92 |
+
|
| 93 |
+
## Simple Text Generation
|
| 94 |
+
|
| 95 |
+
### Basic Prompting
|
| 96 |
+
|
| 97 |
+
Generate text with simple string prompts:
|
| 98 |
+
|
| 99 |
+
```python {6}
|
| 100 |
+
@mcp.tool
|
| 101 |
+
async def generate_summary(content: str, ctx: Context) -> str:
|
| 102 |
+
"""Generate a summary of the provided content."""
|
| 103 |
+
prompt = f"Please provide a concise summary of the following content:\n\n{content}"
|
| 104 |
+
|
| 105 |
+
response = await ctx.sample(prompt)
|
| 106 |
+
return response.text
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
### System Prompt
|
| 110 |
+
|
| 111 |
+
Use system prompts to guide the LLM's behavior:
|
| 112 |
+
|
| 113 |
+
```python {4-9}
|
| 114 |
+
@mcp.tool
|
| 115 |
+
async def generate_code_example(concept: str, ctx: Context) -> str:
|
| 116 |
+
"""Generate a Python code example for a given concept."""
|
| 117 |
+
response = await ctx.sample(
|
| 118 |
+
messages=f"Write a simple Python code example demonstrating '{concept}'.",
|
| 119 |
+
system_prompt="You are an expert Python programmer. Provide concise, working code examples without explanations.",
|
| 120 |
+
temperature=0.7,
|
| 121 |
+
max_tokens=300
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
code_example = response.text
|
| 125 |
+
return f"```python\n{code_example}\n```"
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
### Model Preferences
|
| 130 |
+
|
| 131 |
+
Specify model preferences for different use cases:
|
| 132 |
+
|
| 133 |
+
```python {4-8, 17-22}
|
| 134 |
+
@mcp.tool
|
| 135 |
+
async def creative_writing(topic: str, ctx: Context) -> str:
|
| 136 |
+
"""Generate creative content using a specific model."""
|
| 137 |
+
response = await ctx.sample(
|
| 138 |
+
messages=f"Write a creative short story about {topic}",
|
| 139 |
+
model_preferences="claude-3-sonnet", # Prefer a specific model
|
| 140 |
+
temperature=0.9, # High creativity
|
| 141 |
+
max_tokens=1000
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
return response.text
|
| 145 |
+
|
| 146 |
+
@mcp.tool
|
| 147 |
+
async def technical_analysis(data: str, ctx: Context) -> str:
|
| 148 |
+
"""Perform technical analysis with a reasoning-focused model."""
|
| 149 |
+
response = await ctx.sample(
|
| 150 |
+
messages=f"Analyze this technical data and provide insights: {data}",
|
| 151 |
+
model_preferences=["claude-3-opus", "gpt-4"], # Prefer reasoning models
|
| 152 |
+
temperature=0.2, # Low randomness for consistency
|
| 153 |
+
max_tokens=800
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
return response.text
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
### Complex Message Structures
|
| 160 |
+
|
| 161 |
+
Use structured messages for more complex interactions:
|
| 162 |
+
|
| 163 |
+
```python {1, 6-10}
|
| 164 |
+
from fastmcp.client.sampling import SamplingMessage
|
| 165 |
+
|
| 166 |
+
@mcp.tool
|
| 167 |
+
async def multi_turn_analysis(user_query: str, context_data: str, ctx: Context) -> str:
|
| 168 |
+
"""Perform analysis using multi-turn conversation structure."""
|
| 169 |
+
messages = [
|
| 170 |
+
SamplingMessage(role="user", content=f"I have this data: {context_data}"),
|
| 171 |
+
SamplingMessage(role="assistant", content="I can see your data. What would you like me to analyze?"),
|
| 172 |
+
SamplingMessage(role="user", content=user_query)
|
| 173 |
+
]
|
| 174 |
+
|
| 175 |
+
response = await ctx.sample(
|
| 176 |
+
messages=messages,
|
| 177 |
+
system_prompt="You are a data analyst. Provide detailed insights based on the conversation context.",
|
| 178 |
+
temperature=0.3
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
return response.text
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
## Client Requirements
|
| 185 |
+
|
| 186 |
+
LLM sampling requires client support:
|
| 187 |
+
|
| 188 |
+
- Clients must implement sampling handlers to process requests
|
| 189 |
+
- If the client doesn't support sampling, calls to `ctx.sample()` will fail
|
| 190 |
+
- See [Client Sampling](/clients/sampling) for details on implementing client-side sampling handlers
|
src/fastmcp/client/client.py
CHANGED
|
@@ -16,6 +16,7 @@ from mcp import ClientSession
|
|
| 16 |
from pydantic import AnyUrl
|
| 17 |
|
| 18 |
import fastmcp
|
|
|
|
| 19 |
from fastmcp.client.logging import (
|
| 20 |
LogHandler,
|
| 21 |
create_log_callback,
|
|
@@ -58,6 +59,7 @@ __all__ = [
|
|
| 58 |
"LogHandler",
|
| 59 |
"MessageHandler",
|
| 60 |
"SamplingHandler",
|
|
|
|
| 61 |
"ProgressHandler",
|
| 62 |
]
|
| 63 |
|
|
@@ -154,6 +156,7 @@ class Client(Generic[ClientTransportT]):
|
|
| 154 |
# Common args
|
| 155 |
roots: RootsList | RootsHandler | None = None,
|
| 156 |
sampling_handler: SamplingHandler | None = None,
|
|
|
|
| 157 |
log_handler: LogHandler | None = None,
|
| 158 |
message_handler: MessageHandlerT | MessageHandler | None = None,
|
| 159 |
progress_handler: ProgressHandler | None = None,
|
|
@@ -206,6 +209,11 @@ class Client(Generic[ClientTransportT]):
|
|
| 206 |
sampling_handler
|
| 207 |
)
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
# session context management
|
| 210 |
self._session: ClientSession | None = None
|
| 211 |
self._exit_stack: AsyncExitStack | None = None
|
|
@@ -244,6 +252,14 @@ class Client(Generic[ClientTransportT]):
|
|
| 244 |
sampling_callback
|
| 245 |
)
|
| 246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
def is_connected(self) -> bool:
|
| 248 |
"""Check if the client is currently connected."""
|
| 249 |
return self._session is not None
|
|
|
|
| 16 |
from pydantic import AnyUrl
|
| 17 |
|
| 18 |
import fastmcp
|
| 19 |
+
from fastmcp.client.elicitation import ElicitationHandler, create_elicitation_callback
|
| 20 |
from fastmcp.client.logging import (
|
| 21 |
LogHandler,
|
| 22 |
create_log_callback,
|
|
|
|
| 59 |
"LogHandler",
|
| 60 |
"MessageHandler",
|
| 61 |
"SamplingHandler",
|
| 62 |
+
"ElicitationHandler",
|
| 63 |
"ProgressHandler",
|
| 64 |
]
|
| 65 |
|
|
|
|
| 156 |
# Common args
|
| 157 |
roots: RootsList | RootsHandler | None = None,
|
| 158 |
sampling_handler: SamplingHandler | None = None,
|
| 159 |
+
elicitation_handler: ElicitationHandler | None = None,
|
| 160 |
log_handler: LogHandler | None = None,
|
| 161 |
message_handler: MessageHandlerT | MessageHandler | None = None,
|
| 162 |
progress_handler: ProgressHandler | None = None,
|
|
|
|
| 209 |
sampling_handler
|
| 210 |
)
|
| 211 |
|
| 212 |
+
if elicitation_handler is not None:
|
| 213 |
+
self._session_kwargs["elicitation_callback"] = create_elicitation_callback(
|
| 214 |
+
elicitation_handler
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
# session context management
|
| 218 |
self._session: ClientSession | None = None
|
| 219 |
self._exit_stack: AsyncExitStack | None = None
|
|
|
|
| 252 |
sampling_callback
|
| 253 |
)
|
| 254 |
|
| 255 |
+
def set_elicitation_callback(
|
| 256 |
+
self, elicitation_callback: ElicitationHandler
|
| 257 |
+
) -> None:
|
| 258 |
+
"""Set the elicitation callback for the client."""
|
| 259 |
+
self._session_kwargs["elicitation_callback"] = create_elicitation_callback(
|
| 260 |
+
elicitation_callback
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
def is_connected(self) -> bool:
|
| 264 |
"""Check if the client is currently connected."""
|
| 265 |
return self._session is not None
|
src/fastmcp/client/elicitation.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 36 |
+
def create_elicitation_callback(
|
| 37 |
+
elicitation_handler: ElicitationHandler,
|
| 38 |
+
) -> ElicitationFnT:
|
| 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,
|
| 54 |
+
message=str(e),
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
return _elicitation_handler
|
src/fastmcp/client/transports.py
CHANGED
|
@@ -14,7 +14,13 @@ import anyio
|
|
| 14 |
import httpx
|
| 15 |
import mcp.types
|
| 16 |
from mcp import ClientSession, StdioServerParameters
|
| 17 |
-
from mcp.client.session import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
from mcp.server.fastmcp import FastMCP as FastMCP1Server
|
| 19 |
from mcp.shared.memory import create_client_server_memory_streams
|
| 20 |
from pydantic import AnyUrl
|
|
@@ -55,6 +61,7 @@ class SessionKwargs(TypedDict, total=False):
|
|
| 55 |
sampling_callback: SamplingFnT | None
|
| 56 |
list_roots_callback: ListRootsFnT | None
|
| 57 |
logging_callback: LoggingFnT | None
|
|
|
|
| 58 |
message_handler: MessageHandlerFnT | None
|
| 59 |
client_info: mcp.types.Implementation | None
|
| 60 |
|
|
|
|
| 14 |
import httpx
|
| 15 |
import mcp.types
|
| 16 |
from mcp import ClientSession, StdioServerParameters
|
| 17 |
+
from mcp.client.session import (
|
| 18 |
+
ElicitationFnT,
|
| 19 |
+
ListRootsFnT,
|
| 20 |
+
LoggingFnT,
|
| 21 |
+
MessageHandlerFnT,
|
| 22 |
+
SamplingFnT,
|
| 23 |
+
)
|
| 24 |
from mcp.server.fastmcp import FastMCP as FastMCP1Server
|
| 25 |
from mcp.shared.memory import create_client_server_memory_streams
|
| 26 |
from pydantic import AnyUrl
|
|
|
|
| 61 |
sampling_callback: SamplingFnT | None
|
| 62 |
list_roots_callback: ListRootsFnT | None
|
| 63 |
logging_callback: LoggingFnT | None
|
| 64 |
+
elicitation_callback: ElicitationFnT | None
|
| 65 |
message_handler: MessageHandlerFnT | None
|
| 66 |
client_info: mcp.types.Implementation | None
|
| 67 |
|
src/fastmcp/server/context.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import warnings
|
|
@@ -6,6 +6,8 @@ from collections.abc import Generator
|
|
| 6 |
from contextlib import contextmanager
|
| 7 |
from contextvars import ContextVar, Token
|
| 8 |
from dataclasses import dataclass
|
|
|
|
|
|
|
| 9 |
|
| 10 |
from mcp import LoggingLevel, ServerSession
|
| 11 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
@@ -25,11 +27,20 @@ from starlette.requests import Request
|
|
| 25 |
|
| 26 |
import fastmcp.server.dependencies
|
| 27 |
from fastmcp import settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
from fastmcp.server.server import FastMCP
|
| 29 |
from fastmcp.utilities.logging import get_logger
|
|
|
|
| 30 |
|
| 31 |
logger = get_logger(__name__)
|
| 32 |
|
|
|
|
| 33 |
_current_context: ContextVar[Context | None] = ContextVar("context", default=None)
|
| 34 |
_flush_lock = asyncio.Lock()
|
| 35 |
|
|
@@ -167,7 +178,10 @@ class Context:
|
|
| 167 |
if level is None:
|
| 168 |
level = "info"
|
| 169 |
await self.session.send_log_message(
|
| 170 |
-
level=level,
|
|
|
|
|
|
|
|
|
|
| 171 |
)
|
| 172 |
|
| 173 |
@property
|
|
@@ -293,10 +307,86 @@ class Context:
|
|
| 293 |
temperature=temperature,
|
| 294 |
max_tokens=max_tokens,
|
| 295 |
model_preferences=self._parse_model_preferences(model_preferences),
|
|
|
|
| 296 |
)
|
| 297 |
|
| 298 |
return result.content
|
| 299 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
def get_http_request(self) -> Request:
|
| 301 |
"""Get the active starlette request."""
|
| 302 |
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import warnings
|
|
|
|
| 6 |
from contextlib import contextmanager
|
| 7 |
from contextvars import ContextVar, Token
|
| 8 |
from dataclasses import dataclass
|
| 9 |
+
from enum import Enum
|
| 10 |
+
from typing import Literal, TypeVar, cast, get_origin
|
| 11 |
|
| 12 |
from mcp import LoggingLevel, ServerSession
|
| 13 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
|
|
| 27 |
|
| 28 |
import fastmcp.server.dependencies
|
| 29 |
from fastmcp import settings
|
| 30 |
+
from fastmcp.server.elicitation import (
|
| 31 |
+
AcceptedElicitation,
|
| 32 |
+
CancelledElicitation,
|
| 33 |
+
DeclinedElicitation,
|
| 34 |
+
ScalarElicitationType,
|
| 35 |
+
get_elicitation_schema,
|
| 36 |
+
)
|
| 37 |
from fastmcp.server.server import FastMCP
|
| 38 |
from fastmcp.utilities.logging import get_logger
|
| 39 |
+
from fastmcp.utilities.types import get_cached_typeadapter
|
| 40 |
|
| 41 |
logger = get_logger(__name__)
|
| 42 |
|
| 43 |
+
T = TypeVar("T")
|
| 44 |
_current_context: ContextVar[Context | None] = ContextVar("context", default=None)
|
| 45 |
_flush_lock = asyncio.Lock()
|
| 46 |
|
|
|
|
| 178 |
if level is None:
|
| 179 |
level = "info"
|
| 180 |
await self.session.send_log_message(
|
| 181 |
+
level=level,
|
| 182 |
+
data=message,
|
| 183 |
+
logger=logger_name,
|
| 184 |
+
related_request_id=self.request_id,
|
| 185 |
)
|
| 186 |
|
| 187 |
@property
|
|
|
|
| 307 |
temperature=temperature,
|
| 308 |
max_tokens=max_tokens,
|
| 309 |
model_preferences=self._parse_model_preferences(model_preferences),
|
| 310 |
+
related_request_id=self.request_id,
|
| 311 |
)
|
| 312 |
|
| 313 |
return result.content
|
| 314 |
|
| 315 |
+
async def elicit(
|
| 316 |
+
self,
|
| 317 |
+
message: str,
|
| 318 |
+
response_type: type[T] | list[str] | None = None,
|
| 319 |
+
) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation:
|
| 320 |
+
"""
|
| 321 |
+
Send an elicitation request to the client and await the response.
|
| 322 |
+
|
| 323 |
+
Call this method at any time to request additional information from
|
| 324 |
+
the user through the client. The client must support elicitation,
|
| 325 |
+
or the request will error.
|
| 326 |
+
|
| 327 |
+
Note that the MCP protocol only supports simple object schemas with
|
| 328 |
+
primitive types. You can provide a dataclass, TypedDict, or BaseModel to
|
| 329 |
+
comply. If you provide a primitive type, an object schema with a single
|
| 330 |
+
"value" field will be generated for the MCP interaction and
|
| 331 |
+
automatically deconstructed into the primitive type upon response.
|
| 332 |
+
|
| 333 |
+
Args:
|
| 334 |
+
message: A human-readable message explaining what information is needed
|
| 335 |
+
response_type: The type of the response, which should be a primitive
|
| 336 |
+
type or dataclass or BaseModel. If it is a primitive type, an
|
| 337 |
+
object schema with a single "value" field will be generated.
|
| 338 |
+
"""
|
| 339 |
+
if response_type is None:
|
| 340 |
+
response_type = str # type: ignore
|
| 341 |
+
|
| 342 |
+
# if the user provided a list of strings, treat it as a Literal
|
| 343 |
+
if isinstance(response_type, list):
|
| 344 |
+
if not all(isinstance(item, str) for item in response_type):
|
| 345 |
+
raise ValueError(
|
| 346 |
+
"List of options must be a list of strings. Received: "
|
| 347 |
+
f"{response_type}"
|
| 348 |
+
)
|
| 349 |
+
# Convert list of options to Literal type and wrap
|
| 350 |
+
choice_literal = Literal[tuple(response_type)] # type: ignore
|
| 351 |
+
response_type = ScalarElicitationType[choice_literal] # type: ignore
|
| 352 |
+
# if the user provided a primitive scalar, wrap it in an object schema
|
| 353 |
+
elif response_type in {bool, int, float, str}:
|
| 354 |
+
response_type = ScalarElicitationType[response_type] # type: ignore
|
| 355 |
+
# if the user provided a Literal type, wrap it in an object schema
|
| 356 |
+
elif get_origin(response_type) is Literal:
|
| 357 |
+
response_type = ScalarElicitationType[response_type] # type: ignore
|
| 358 |
+
# if the user provided an Enum type, wrap it in an object schema
|
| 359 |
+
elif isinstance(response_type, type) and issubclass(response_type, Enum):
|
| 360 |
+
response_type = ScalarElicitationType[response_type] # type: ignore
|
| 361 |
+
|
| 362 |
+
response_type = cast(type[T], response_type)
|
| 363 |
+
|
| 364 |
+
requested_schema = get_elicitation_schema(response_type)
|
| 365 |
+
|
| 366 |
+
result = await self.session.elicit(
|
| 367 |
+
message=message,
|
| 368 |
+
requestedSchema=requested_schema,
|
| 369 |
+
related_request_id=self.request_id,
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
if result.action == "accept" and result.content:
|
| 373 |
+
type_adapter = get_cached_typeadapter(response_type)
|
| 374 |
+
validated_data = cast(
|
| 375 |
+
T | ScalarElicitationType[T],
|
| 376 |
+
type_adapter.validate_python(result.content),
|
| 377 |
+
)
|
| 378 |
+
if isinstance(validated_data, ScalarElicitationType):
|
| 379 |
+
return AcceptedElicitation[T](data=validated_data.value)
|
| 380 |
+
else:
|
| 381 |
+
return AcceptedElicitation[T](data=validated_data)
|
| 382 |
+
elif result.action == "decline":
|
| 383 |
+
return DeclinedElicitation()
|
| 384 |
+
elif result.action == "cancel":
|
| 385 |
+
return CancelledElicitation()
|
| 386 |
+
else:
|
| 387 |
+
# This should never happen, but handle it just in case
|
| 388 |
+
raise ValueError(f"Unexpected elicitation action: {result.action}")
|
| 389 |
+
|
| 390 |
def get_http_request(self) -> Request:
|
| 391 |
"""Get the active starlette request."""
|
| 392 |
|
src/fastmcp/server/elicitation.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Any, Generic, Literal, TypeVar
|
| 5 |
+
|
| 6 |
+
from mcp.server.elicitation import (
|
| 7 |
+
CancelledElicitation,
|
| 8 |
+
DeclinedElicitation,
|
| 9 |
+
)
|
| 10 |
+
from pydantic import BaseModel
|
| 11 |
+
|
| 12 |
+
from fastmcp.utilities.json_schema import compress_schema
|
| 13 |
+
from fastmcp.utilities.logging import get_logger
|
| 14 |
+
from fastmcp.utilities.types import get_cached_typeadapter
|
| 15 |
+
|
| 16 |
+
__all__ = [
|
| 17 |
+
"AcceptedElicitation",
|
| 18 |
+
"CancelledElicitation",
|
| 19 |
+
"DeclinedElicitation",
|
| 20 |
+
"get_elicitation_schema",
|
| 21 |
+
"ScalarElicitationType",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
logger = get_logger(__name__)
|
| 25 |
+
|
| 26 |
+
T = TypeVar("T")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# we can't use the low-level AcceptedElicitation because it only works with BaseModels
|
| 30 |
+
class AcceptedElicitation(BaseModel, Generic[T]):
|
| 31 |
+
"""Result when user accepts the elicitation."""
|
| 32 |
+
|
| 33 |
+
action: Literal["accept"] = "accept"
|
| 34 |
+
data: T
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class ScalarElicitationType(Generic[T]):
|
| 39 |
+
value: T
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def get_elicitation_schema(response_type: type[T]) -> dict[str, Any]:
|
| 43 |
+
"""Get the schema for an elicitation response.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
response_type: The type of the response
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
schema = get_cached_typeadapter(response_type).json_schema()
|
| 50 |
+
schema = compress_schema(schema)
|
| 51 |
+
|
| 52 |
+
# Validate the schema to ensure it follows MCP elicitation requirements
|
| 53 |
+
validate_elicitation_json_schema(schema)
|
| 54 |
+
|
| 55 |
+
return schema
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def validate_elicitation_json_schema(schema: dict[str, Any]) -> None:
|
| 59 |
+
"""Validate that a JSON schema follows MCP elicitation requirements.
|
| 60 |
+
|
| 61 |
+
This ensures the schema is compatible with MCP elicitation requirements:
|
| 62 |
+
- Must be an object schema
|
| 63 |
+
- Must only contain primitive field types (string, number, integer, boolean)
|
| 64 |
+
- Must be flat (no nested objects or arrays of objects)
|
| 65 |
+
- 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:
|
| 69 |
+
schema: The JSON schema to validate
|
| 70 |
+
|
| 71 |
+
Raises:
|
| 72 |
+
TypeError: If the schema doesn't meet MCP elicitation requirements
|
| 73 |
+
"""
|
| 74 |
+
ALLOWED_TYPES = {"string", "number", "integer", "boolean"}
|
| 75 |
+
|
| 76 |
+
# Check that the schema is an object
|
| 77 |
+
if schema.get("type") != "object":
|
| 78 |
+
raise TypeError(
|
| 79 |
+
f"Elicitation schema must be an object schema, got type '{schema.get('type')}'. "
|
| 80 |
+
"Elicitation schemas are limited to flat objects with primitive properties only."
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
properties = schema.get("properties", {})
|
| 84 |
+
if not properties:
|
| 85 |
+
raise TypeError(
|
| 86 |
+
"Elicitation schema must have at least one property. "
|
| 87 |
+
"Empty object schemas are not allowed."
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
for prop_name, prop_schema in properties.items():
|
| 91 |
+
prop_type = prop_schema.get("type")
|
| 92 |
+
|
| 93 |
+
# Handle nullable types
|
| 94 |
+
if isinstance(prop_type, list):
|
| 95 |
+
if "null" in prop_type:
|
| 96 |
+
prop_type = [t for t in prop_type if t != "null"]
|
| 97 |
+
if len(prop_type) == 1:
|
| 98 |
+
prop_type = prop_type[0]
|
| 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(
|
| 140 |
+
f"Elicitation schema field '{prop_name}' has union type '{union_type}' which is not "
|
| 141 |
+
f"a primitive type. Only {ALLOWED_TYPES} are allowed in elicitation schemas."
|
| 142 |
+
)
|
| 143 |
+
continue
|
| 144 |
+
|
| 145 |
+
# Check if it's a primitive type
|
| 146 |
+
if prop_type not in ALLOWED_TYPES:
|
| 147 |
+
raise TypeError(
|
| 148 |
+
f"Elicitation schema field '{prop_name}' has type '{prop_type}' which is not "
|
| 149 |
+
f"a primitive type. Only {ALLOWED_TYPES} are allowed in elicitation schemas."
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
# Check for nested objects or arrays of objects (not allowed)
|
| 153 |
+
if prop_type == "object":
|
| 154 |
+
raise TypeError(
|
| 155 |
+
f"Elicitation schema field '{prop_name}' is an object, but nested objects are not allowed. "
|
| 156 |
+
"Elicitation schemas must be flat objects with primitive properties only."
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
if prop_type == "array":
|
| 160 |
+
items_schema = prop_schema.get("items", {})
|
| 161 |
+
if items_schema.get("type") == "object":
|
| 162 |
+
raise TypeError(
|
| 163 |
+
f"Elicitation schema field '{prop_name}' is an array of objects, but arrays of objects are not allowed. "
|
| 164 |
+
"Elicitation schemas must be flat objects with primitive properties only."
|
| 165 |
+
)
|
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
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import asdict, dataclass
|
| 2 |
+
from enum import Enum
|
| 3 |
+
from typing import Literal
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
from typing_extensions import TypedDict
|
| 8 |
+
|
| 9 |
+
from fastmcp import Context, FastMCP
|
| 10 |
+
from fastmcp.client.client import Client
|
| 11 |
+
from fastmcp.client.elicitation import ElicitResult
|
| 12 |
+
from fastmcp.exceptions import ToolError
|
| 13 |
+
from fastmcp.server.elicitation import (
|
| 14 |
+
AcceptedElicitation,
|
| 15 |
+
CancelledElicitation,
|
| 16 |
+
DeclinedElicitation,
|
| 17 |
+
)
|
| 18 |
+
from fastmcp.utilities.types import TypeAdapter
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture
|
| 22 |
+
def fastmcp_server():
|
| 23 |
+
mcp = FastMCP("TestServer")
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class Person:
|
| 27 |
+
name: str
|
| 28 |
+
|
| 29 |
+
@mcp.tool
|
| 30 |
+
async def ask_for_name(context: Context) -> str:
|
| 31 |
+
result = await context.elicit(
|
| 32 |
+
message="What is your name?",
|
| 33 |
+
response_type=Person,
|
| 34 |
+
)
|
| 35 |
+
if result.action == "accept":
|
| 36 |
+
return f"Hello, {result.data.name}!"
|
| 37 |
+
else:
|
| 38 |
+
return "No name provided."
|
| 39 |
+
|
| 40 |
+
@mcp.tool
|
| 41 |
+
def simple_test() -> str:
|
| 42 |
+
return "Hello!"
|
| 43 |
+
|
| 44 |
+
return mcp
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
async def test_elicitation_with_no_handler(fastmcp_server):
|
| 48 |
+
"""Test that elicitation works without a handler."""
|
| 49 |
+
|
| 50 |
+
async with Client(fastmcp_server) as client:
|
| 51 |
+
with pytest.raises(ToolError, match="Elicitation not supported"):
|
| 52 |
+
await client.call_tool("ask_for_name", {})
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def test_elicitation_accept_content(fastmcp_server):
|
| 56 |
+
"""Test basic elicitation functionality."""
|
| 57 |
+
|
| 58 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 59 |
+
# Mock user providing their name
|
| 60 |
+
return ElicitResult(action="accept", content=response_type(name="Alice"))
|
| 61 |
+
|
| 62 |
+
async with Client(
|
| 63 |
+
fastmcp_server, elicitation_handler=elicitation_handler
|
| 64 |
+
) as client:
|
| 65 |
+
result = await client.call_tool("ask_for_name", {})
|
| 66 |
+
assert result.data == "Hello, Alice!"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def test_elicitation_decline(fastmcp_server):
|
| 70 |
+
"""Test that elicitation handler receives correct parameters."""
|
| 71 |
+
|
| 72 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 73 |
+
return ElicitResult(action="decline")
|
| 74 |
+
|
| 75 |
+
async with Client(
|
| 76 |
+
fastmcp_server, elicitation_handler=elicitation_handler
|
| 77 |
+
) as client:
|
| 78 |
+
result = await client.call_tool("ask_for_name", {})
|
| 79 |
+
assert result.data == "No name provided."
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def test_default_response_type(fastmcp_server):
|
| 83 |
+
"""Test elicitation with string content."""
|
| 84 |
+
mcp = FastMCP("TestServer")
|
| 85 |
+
|
| 86 |
+
@mcp.tool
|
| 87 |
+
async def ask_for_color(context: Context) -> str:
|
| 88 |
+
result = await context.elicit(
|
| 89 |
+
message="What is your favorite color?"
|
| 90 |
+
# Default schema should be string
|
| 91 |
+
)
|
| 92 |
+
if result.action == "accept":
|
| 93 |
+
assert isinstance(result.data, str)
|
| 94 |
+
return f"Your favorite color is {result.data}!"
|
| 95 |
+
return "No color provided"
|
| 96 |
+
|
| 97 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 98 |
+
# Mock user providing their favorite color as string in content dict
|
| 99 |
+
return ElicitResult(action="accept", content={"value": "blue"})
|
| 100 |
+
|
| 101 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 102 |
+
result = await client.call_tool("ask_for_color", {})
|
| 103 |
+
assert result.data == "Your favorite color is blue!"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
async def test_elicitation_handler_parameters():
|
| 107 |
+
"""Test that elicitation handler receives correct parameters."""
|
| 108 |
+
mcp = FastMCP("TestServer")
|
| 109 |
+
captured_params = {}
|
| 110 |
+
|
| 111 |
+
@mcp.tool
|
| 112 |
+
async def test_tool(context: Context) -> str:
|
| 113 |
+
await context.elicit(
|
| 114 |
+
message="Test message",
|
| 115 |
+
response_type=int,
|
| 116 |
+
)
|
| 117 |
+
return "done"
|
| 118 |
+
|
| 119 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 120 |
+
captured_params["message"] = message
|
| 121 |
+
captured_params["response_type"] = str(response_type)
|
| 122 |
+
captured_params["params"] = params
|
| 123 |
+
captured_params["ctx"] = ctx
|
| 124 |
+
return ElicitResult(action="accept", content={"value": 42})
|
| 125 |
+
|
| 126 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 127 |
+
await client.call_tool("test_tool", {})
|
| 128 |
+
|
| 129 |
+
assert captured_params["message"] == "Test message"
|
| 130 |
+
assert "ScalarElicitationType" in str(captured_params["response_type"])
|
| 131 |
+
assert captured_params["params"].requestedSchema == {
|
| 132 |
+
"properties": {"value": {"title": "Value", "type": "integer"}},
|
| 133 |
+
"required": ["value"],
|
| 134 |
+
"title": "ScalarElicitationType",
|
| 135 |
+
"type": "object",
|
| 136 |
+
}
|
| 137 |
+
assert captured_params["ctx"] is not None
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def test_elicitation_cancel_action():
|
| 141 |
+
"""Test user canceling elicitation request."""
|
| 142 |
+
mcp = FastMCP("TestServer")
|
| 143 |
+
|
| 144 |
+
@mcp.tool
|
| 145 |
+
async def ask_for_optional_info(context: Context) -> str:
|
| 146 |
+
result = await context.elicit(
|
| 147 |
+
message="Optional: What's your age?", response_type=int
|
| 148 |
+
)
|
| 149 |
+
if result.action == "cancel":
|
| 150 |
+
return "Request was canceled"
|
| 151 |
+
elif result.action == "accept":
|
| 152 |
+
return f"Age: {result.data}"
|
| 153 |
+
else:
|
| 154 |
+
return "No response provided"
|
| 155 |
+
|
| 156 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 157 |
+
return ElicitResult(action="cancel")
|
| 158 |
+
|
| 159 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 160 |
+
result = await client.call_tool("ask_for_optional_info", {})
|
| 161 |
+
assert result.data == "Request was canceled"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
class TestScalarResponseTypes:
|
| 165 |
+
async def test_elicitation_str_response(self):
|
| 166 |
+
"""Test elicitation with string schema."""
|
| 167 |
+
mcp = FastMCP("TestServer")
|
| 168 |
+
|
| 169 |
+
@mcp.tool
|
| 170 |
+
async def my_tool(context: Context) -> str:
|
| 171 |
+
result = await context.elicit(message="", response_type=str)
|
| 172 |
+
return result.data # type: ignore[attr-defined]
|
| 173 |
+
|
| 174 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 175 |
+
return ElicitResult(action="accept", content={"value": "hello"})
|
| 176 |
+
|
| 177 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 178 |
+
result = await client.call_tool("my_tool", {})
|
| 179 |
+
assert result.data == "hello"
|
| 180 |
+
|
| 181 |
+
async def test_elicitation_int_response(self):
|
| 182 |
+
"""Test elicitation with number schema."""
|
| 183 |
+
mcp = FastMCP("TestServer")
|
| 184 |
+
|
| 185 |
+
@mcp.tool
|
| 186 |
+
async def my_tool(context: Context) -> int:
|
| 187 |
+
result = await context.elicit(message="", response_type=int)
|
| 188 |
+
return result.data # type: ignore[attr-defined]
|
| 189 |
+
|
| 190 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 191 |
+
return ElicitResult(action="accept", content={"value": 42})
|
| 192 |
+
|
| 193 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 194 |
+
result = await client.call_tool("my_tool", {})
|
| 195 |
+
assert result.data == 42
|
| 196 |
+
|
| 197 |
+
async def test_elicitation_float_response(self):
|
| 198 |
+
"""Test elicitation with number schema."""
|
| 199 |
+
mcp = FastMCP("TestServer")
|
| 200 |
+
|
| 201 |
+
@mcp.tool
|
| 202 |
+
async def my_tool(context: Context) -> float:
|
| 203 |
+
result = await context.elicit(message="", response_type=float)
|
| 204 |
+
return result.data # type: ignore[attr-defined]
|
| 205 |
+
|
| 206 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 207 |
+
return ElicitResult(action="accept", content={"value": 3.14})
|
| 208 |
+
|
| 209 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 210 |
+
result = await client.call_tool("my_tool", {})
|
| 211 |
+
assert result.data == 3.14
|
| 212 |
+
|
| 213 |
+
async def test_elicitation_bool_response(self):
|
| 214 |
+
"""Test elicitation with boolean schema."""
|
| 215 |
+
mcp = FastMCP("TestServer")
|
| 216 |
+
|
| 217 |
+
@mcp.tool
|
| 218 |
+
async def my_tool(context: Context) -> bool:
|
| 219 |
+
result = await context.elicit(message="", response_type=bool)
|
| 220 |
+
return result.data # type: ignore[attr-defined]
|
| 221 |
+
|
| 222 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 223 |
+
return ElicitResult(action="accept", content={"value": True})
|
| 224 |
+
|
| 225 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 226 |
+
result = await client.call_tool("my_tool", {})
|
| 227 |
+
assert result.data is True
|
| 228 |
+
|
| 229 |
+
async def test_elicitation_literal_response(self):
|
| 230 |
+
"""Test elicitation with literal schema."""
|
| 231 |
+
mcp = FastMCP("TestServer")
|
| 232 |
+
|
| 233 |
+
@mcp.tool
|
| 234 |
+
async def my_tool(context: Context) -> Literal["x", "y"]:
|
| 235 |
+
result = await context.elicit(message="", response_type=Literal["x", "y"]) # type: ignore
|
| 236 |
+
return result.data # type: ignore[attr-defined]
|
| 237 |
+
|
| 238 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 239 |
+
return ElicitResult(action="accept", content={"value": "x"})
|
| 240 |
+
|
| 241 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 242 |
+
result = await client.call_tool("my_tool", {})
|
| 243 |
+
assert result.data == "x"
|
| 244 |
+
|
| 245 |
+
async def test_elicitation_enum_response(self):
|
| 246 |
+
"""Test elicitation with enum schema."""
|
| 247 |
+
mcp = FastMCP("TestServer")
|
| 248 |
+
|
| 249 |
+
class ResponseEnum(Enum):
|
| 250 |
+
X = "x"
|
| 251 |
+
Y = "y"
|
| 252 |
+
|
| 253 |
+
@mcp.tool
|
| 254 |
+
async def my_tool(context: Context) -> ResponseEnum:
|
| 255 |
+
result = await context.elicit(message="", response_type=ResponseEnum)
|
| 256 |
+
return result.data # type: ignore[attr-defined]
|
| 257 |
+
|
| 258 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 259 |
+
return ElicitResult(action="accept", content={"value": "x"})
|
| 260 |
+
|
| 261 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 262 |
+
result = await client.call_tool("my_tool", {})
|
| 263 |
+
assert result.data == "x"
|
| 264 |
+
|
| 265 |
+
async def test_elicitation_list_response(self):
|
| 266 |
+
"""Test elicitation with list schema."""
|
| 267 |
+
mcp = FastMCP("TestServer")
|
| 268 |
+
|
| 269 |
+
@mcp.tool
|
| 270 |
+
async def my_tool(context: Context) -> str:
|
| 271 |
+
result = await context.elicit(message="", response_type=["x", "y"])
|
| 272 |
+
return result.data # type: ignore[attr-defined]
|
| 273 |
+
|
| 274 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 275 |
+
return ElicitResult(action="accept", content={"value": "x"})
|
| 276 |
+
|
| 277 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 278 |
+
result = await client.call_tool("my_tool", {})
|
| 279 |
+
assert result.data == "x"
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
async def test_elicitation_handler_error():
|
| 283 |
+
"""Test error handling in elicitation handler."""
|
| 284 |
+
mcp = FastMCP("TestServer")
|
| 285 |
+
|
| 286 |
+
@mcp.tool
|
| 287 |
+
async def failing_elicit(context: Context) -> str:
|
| 288 |
+
try:
|
| 289 |
+
result = await context.elicit(message="This will fail", response_type=str)
|
| 290 |
+
assert result.action == "accept"
|
| 291 |
+
return f"Got: {result.data}"
|
| 292 |
+
except Exception as e:
|
| 293 |
+
return f"Error: {str(e)}"
|
| 294 |
+
|
| 295 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 296 |
+
raise ValueError("Handler failed!")
|
| 297 |
+
|
| 298 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 299 |
+
result = await client.call_tool("failing_elicit", {})
|
| 300 |
+
assert "Error:" in result.data
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
async def test_elicitation_multiple_calls():
|
| 304 |
+
"""Test multiple elicitation calls in sequence."""
|
| 305 |
+
mcp = FastMCP("TestServer")
|
| 306 |
+
|
| 307 |
+
@mcp.tool
|
| 308 |
+
async def multi_step_form(context: Context) -> str:
|
| 309 |
+
# First question
|
| 310 |
+
name_result = await context.elicit(
|
| 311 |
+
message="What's your name?", response_type=str
|
| 312 |
+
)
|
| 313 |
+
if name_result.action != "accept":
|
| 314 |
+
return "Form abandoned"
|
| 315 |
+
|
| 316 |
+
# Second question
|
| 317 |
+
age_result = await context.elicit(message="What's your age?", response_type=int)
|
| 318 |
+
if age_result.action != "accept":
|
| 319 |
+
return f"Hello {name_result.data}, form incomplete"
|
| 320 |
+
|
| 321 |
+
return f"Hello {name_result.data}, you are {age_result.data} years old"
|
| 322 |
+
|
| 323 |
+
call_count = 0
|
| 324 |
+
|
| 325 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 326 |
+
nonlocal call_count
|
| 327 |
+
call_count += 1
|
| 328 |
+
if call_count == 1:
|
| 329 |
+
return ElicitResult(action="accept", content={"value": "Bob"})
|
| 330 |
+
elif call_count == 2:
|
| 331 |
+
return ElicitResult(action="accept", content={"value": 25})
|
| 332 |
+
else:
|
| 333 |
+
raise ValueError("Unexpected call")
|
| 334 |
+
|
| 335 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 336 |
+
result = await client.call_tool("multi_step_form", {})
|
| 337 |
+
assert result.data == "Hello Bob, you are 25 years old"
|
| 338 |
+
assert call_count == 2
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
@dataclass
|
| 342 |
+
class UserInfo:
|
| 343 |
+
name: str
|
| 344 |
+
age: int
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
class UserInfoTypedDict(TypedDict):
|
| 348 |
+
name: str
|
| 349 |
+
age: int
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
class UserInfoPydantic(BaseModel):
|
| 353 |
+
name: str
|
| 354 |
+
age: int
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
@pytest.mark.parametrize(
|
| 358 |
+
"structured_type", [UserInfo, UserInfoTypedDict, UserInfoPydantic]
|
| 359 |
+
)
|
| 360 |
+
async def test_structured_response_type(
|
| 361 |
+
structured_type: type[UserInfo | UserInfoTypedDict | UserInfoPydantic],
|
| 362 |
+
):
|
| 363 |
+
"""Test elicitation with dataclass response type."""
|
| 364 |
+
mcp = FastMCP("TestServer")
|
| 365 |
+
|
| 366 |
+
@mcp.tool
|
| 367 |
+
async def get_user_info(context: Context) -> str:
|
| 368 |
+
result = await context.elicit(
|
| 369 |
+
message="Please provide your information", response_type=structured_type
|
| 370 |
+
)
|
| 371 |
+
if result.action == "accept":
|
| 372 |
+
if isinstance(result.data, dict):
|
| 373 |
+
return f"User: {result.data['name']}, age: {result.data['age']}"
|
| 374 |
+
else:
|
| 375 |
+
return f"User: {result.data.name}, age: {result.data.age}"
|
| 376 |
+
return "No user info provided"
|
| 377 |
+
|
| 378 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 379 |
+
# Verify we get the dataclass type
|
| 380 |
+
assert (
|
| 381 |
+
TypeAdapter(response_type).json_schema()
|
| 382 |
+
== TypeAdapter(structured_type).json_schema()
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
# Verify the schema has the dataclass fields (available in params)
|
| 386 |
+
schema = params.requestedSchema
|
| 387 |
+
assert schema["type"] == "object"
|
| 388 |
+
assert "name" in schema["properties"]
|
| 389 |
+
assert "age" in schema["properties"]
|
| 390 |
+
assert schema["properties"]["name"]["type"] == "string"
|
| 391 |
+
assert schema["properties"]["age"]["type"] == "integer"
|
| 392 |
+
|
| 393 |
+
return ElicitResult(action="accept", content=UserInfo(name="Alice", age=30))
|
| 394 |
+
|
| 395 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 396 |
+
result = await client.call_tool("get_user_info", {})
|
| 397 |
+
assert result.data == "User: Alice, age: 30"
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
async def test_all_primitive_field_types():
|
| 401 |
+
class DataEnum(Enum):
|
| 402 |
+
X = "x"
|
| 403 |
+
Y = "y"
|
| 404 |
+
|
| 405 |
+
@dataclass
|
| 406 |
+
class Data:
|
| 407 |
+
integer: int
|
| 408 |
+
float_: float
|
| 409 |
+
number: int | float
|
| 410 |
+
boolean: bool
|
| 411 |
+
string: str
|
| 412 |
+
constant: Literal["x"]
|
| 413 |
+
union: Literal["x"] | Literal["y"]
|
| 414 |
+
choice: Literal["x", "y"]
|
| 415 |
+
enum: DataEnum
|
| 416 |
+
|
| 417 |
+
mcp = FastMCP("TestServer")
|
| 418 |
+
|
| 419 |
+
@mcp.tool
|
| 420 |
+
async def get_data(context: Context) -> Data:
|
| 421 |
+
result = await context.elicit(message="Enter data", response_type=Data)
|
| 422 |
+
return result.data # type: ignore[attr-defined]
|
| 423 |
+
|
| 424 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 425 |
+
return ElicitResult(
|
| 426 |
+
action="accept",
|
| 427 |
+
content=Data(
|
| 428 |
+
integer=1,
|
| 429 |
+
float_=1.0,
|
| 430 |
+
number=1.0,
|
| 431 |
+
boolean=True,
|
| 432 |
+
string="hello",
|
| 433 |
+
constant="x",
|
| 434 |
+
union="x",
|
| 435 |
+
choice="x",
|
| 436 |
+
enum=DataEnum.X,
|
| 437 |
+
),
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 441 |
+
result = await client.call_tool("get_data", {})
|
| 442 |
+
|
| 443 |
+
# Now all literal/enum fields should be preserved as strings
|
| 444 |
+
result_data = asdict(result.data)
|
| 445 |
+
result_data_enum = result_data.pop("enum")
|
| 446 |
+
assert result_data_enum == "x" # Should be a string now, not an enum
|
| 447 |
+
assert result_data == {
|
| 448 |
+
"integer": 1,
|
| 449 |
+
"float_": 1.0,
|
| 450 |
+
"number": 1.0,
|
| 451 |
+
"boolean": True,
|
| 452 |
+
"string": "hello",
|
| 453 |
+
"constant": "x",
|
| 454 |
+
"union": "x",
|
| 455 |
+
"choice": "x",
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
class TestValidation:
|
| 460 |
+
async def test_schema_validation_rejects_non_object(self):
|
| 461 |
+
"""Test that non-object schemas are rejected."""
|
| 462 |
+
from fastmcp.server.elicitation import validate_elicitation_json_schema
|
| 463 |
+
|
| 464 |
+
with pytest.raises(TypeError, match="must be an object schema"):
|
| 465 |
+
validate_elicitation_json_schema({"type": "string"})
|
| 466 |
+
|
| 467 |
+
async def test_schema_validation_rejects_empty_object(self):
|
| 468 |
+
"""Test that object schemas without properties are rejected."""
|
| 469 |
+
from fastmcp.server.elicitation import validate_elicitation_json_schema
|
| 470 |
+
|
| 471 |
+
with pytest.raises(TypeError, match="must have at least one property"):
|
| 472 |
+
validate_elicitation_json_schema({"type": "object"})
|
| 473 |
+
|
| 474 |
+
async def test_schema_validation_rejects_nested_objects(self):
|
| 475 |
+
"""Test that nested object schemas are rejected."""
|
| 476 |
+
from fastmcp.server.elicitation import validate_elicitation_json_schema
|
| 477 |
+
|
| 478 |
+
with pytest.raises(
|
| 479 |
+
TypeError, match="has type 'object' which is not a primitive type"
|
| 480 |
+
):
|
| 481 |
+
validate_elicitation_json_schema(
|
| 482 |
+
{
|
| 483 |
+
"type": "object",
|
| 484 |
+
"properties": {
|
| 485 |
+
"user": {
|
| 486 |
+
"type": "object",
|
| 487 |
+
"properties": {"name": {"type": "string"}},
|
| 488 |
+
}
|
| 489 |
+
},
|
| 490 |
+
}
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
async def test_schema_validation_rejects_arrays(self):
|
| 494 |
+
"""Test that array schemas are rejected."""
|
| 495 |
+
from fastmcp.server.elicitation import validate_elicitation_json_schema
|
| 496 |
+
|
| 497 |
+
with pytest.raises(
|
| 498 |
+
TypeError, match="has type 'array' which is not a primitive type"
|
| 499 |
+
):
|
| 500 |
+
validate_elicitation_json_schema(
|
| 501 |
+
{
|
| 502 |
+
"type": "object",
|
| 503 |
+
"properties": {
|
| 504 |
+
"users": {"type": "array", "items": {"type": "string"}}
|
| 505 |
+
},
|
| 506 |
+
}
|
| 507 |
+
)
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
class TestPatternMatching:
|
| 511 |
+
async def test_pattern_matching_accept(self):
|
| 512 |
+
"""Test pattern matching with AcceptedElicitation."""
|
| 513 |
+
mcp = FastMCP("TestServer")
|
| 514 |
+
|
| 515 |
+
@mcp.tool
|
| 516 |
+
async def pattern_match_tool(context: Context) -> str:
|
| 517 |
+
result = await context.elicit("Enter your name:", response_type=str)
|
| 518 |
+
|
| 519 |
+
match result:
|
| 520 |
+
case AcceptedElicitation(data=name):
|
| 521 |
+
return f"Hello {name}!"
|
| 522 |
+
case DeclinedElicitation():
|
| 523 |
+
return "You declined"
|
| 524 |
+
case CancelledElicitation():
|
| 525 |
+
return "Cancelled"
|
| 526 |
+
|
| 527 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 528 |
+
return ElicitResult(action="accept", content={"value": "Alice"})
|
| 529 |
+
|
| 530 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 531 |
+
result = await client.call_tool("pattern_match_tool", {})
|
| 532 |
+
assert result.data == "Hello Alice!"
|
| 533 |
+
|
| 534 |
+
async def test_pattern_matching_decline(self):
|
| 535 |
+
"""Test pattern matching with DeclinedElicitation."""
|
| 536 |
+
mcp = FastMCP("TestServer")
|
| 537 |
+
|
| 538 |
+
@mcp.tool
|
| 539 |
+
async def pattern_match_tool(context: Context) -> str:
|
| 540 |
+
result = await context.elicit("Enter your name:", response_type=str)
|
| 541 |
+
|
| 542 |
+
match result:
|
| 543 |
+
case AcceptedElicitation(data=name):
|
| 544 |
+
return f"Hello {name}!"
|
| 545 |
+
case DeclinedElicitation():
|
| 546 |
+
return "You declined"
|
| 547 |
+
case CancelledElicitation():
|
| 548 |
+
return "Cancelled"
|
| 549 |
+
|
| 550 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 551 |
+
return ElicitResult(action="decline")
|
| 552 |
+
|
| 553 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 554 |
+
result = await client.call_tool("pattern_match_tool", {})
|
| 555 |
+
assert result.data == "You declined"
|
| 556 |
+
|
| 557 |
+
async def test_pattern_matching_cancel(self):
|
| 558 |
+
"""Test pattern matching with CancelledElicitation."""
|
| 559 |
+
mcp = FastMCP("TestServer")
|
| 560 |
+
|
| 561 |
+
@mcp.tool
|
| 562 |
+
async def pattern_match_tool(context: Context) -> str:
|
| 563 |
+
result = await context.elicit("Enter your name:", response_type=str)
|
| 564 |
+
|
| 565 |
+
match result:
|
| 566 |
+
case AcceptedElicitation(data=name):
|
| 567 |
+
return f"Hello {name}!"
|
| 568 |
+
case DeclinedElicitation():
|
| 569 |
+
return "You declined"
|
| 570 |
+
case CancelledElicitation():
|
| 571 |
+
return "Cancelled"
|
| 572 |
+
|
| 573 |
+
async def elicitation_handler(message, response_type, params, ctx):
|
| 574 |
+
return ElicitResult(action="cancel")
|
| 575 |
+
|
| 576 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 577 |
+
result = await client.call_tool("pattern_match_tool", {})
|
| 578 |
+
assert result.data == "Cancelled"
|
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."""
|