Jeremiah Lowin commited on
Commit
54f7c6e
·
unverified ·
2 Parent(s): 2efc46ef176f90

Merge pull request #889 from jlowin/elicitation

Browse files
CLAUDE.md CHANGED
@@ -1,5 +1,10 @@
1
  # FastMCP Development Guidelines
2
 
 
 
 
 
 
3
  ## Testing and Investigation
4
 
5
  ### In-Memory Transport - Always Preferred
@@ -34,4 +39,4 @@ async with Client(transport=StreamableHttpTransport(server_url)) as client:
34
  - You must always run pre-commit if you open a PR, because it is run as part of a required check.
35
  - When opening PRs, apply labels appropriately for bugs/breaking changes/enhancements/features. Generally, improvements are enhancements (not features) unless told otherwise.
36
  - NEVER modify files in docs/python-sdk/**, as they are auto-generated.
37
- - Use # type: ignore[attr-defined] in unit tests when accessing an MCP result of indeterminate type instead of asserting its type
 
1
  # FastMCP Development Guidelines
2
 
3
+ ## Documentation
4
+
5
+ - Documentation uses the Mintlify framework
6
+ - Files must be present in docs.json to be included in the documentation
7
+
8
  ## Testing and Investigation
9
 
10
  ### In-Memory Transport - Always Preferred
 
39
  - You must always run pre-commit if you open a PR, because it is run as part of a required check.
40
  - When opening PRs, apply labels appropriately for bugs/breaking changes/enhancements/features. Generally, improvements are enhancements (not features) unless told otherwise.
41
  - NEVER modify files in docs/python-sdk/**, as they are auto-generated.
42
+ - Use # type: ignore[attr-defined] in unit tests when accessing an MCP result of indeterminate type instead of asserting its type
docs/clients/elicitation.mdx ADDED
@@ -0,0 +1,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
- "servers/context"
 
 
 
 
 
 
 
 
 
 
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="ContextDemo")
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, Context
92
  from fastmcp.server.dependencies import get_context
93
 
94
- mcp = FastMCP(name="DependencyDemo")
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 log messages back to the MCP client. This is useful for debugging and providing visibility into function execution during a request.
119
 
120
  ```python
121
- @mcp.tool
122
- async def analyze_data(data: list[float], ctx: Context) -> dict:
123
- """Analyze numerical data with logging."""
124
- await ctx.debug("Starting analysis of numerical data")
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
- **Available Logging Methods:**
 
140
 
141
- - **`ctx.debug(message: str)`**: Low-level details useful for debugging
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
- ### Progress Reporting
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
- @mcp.tool
153
- async def process_items(items: list[str], ctx: Context) -> dict:
154
- """Process a list of items with progress updates."""
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
- **Method signature:**
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
- Progress reporting requires the client to have sent a `progressToken` in the initial request. If the client doesn't support progress reporting, these calls will have no effect.
179
 
180
- ### Resource Access
181
 
182
- Read data from resources registered with your FastMCP server. This allows functions to access files, configuration, or dynamically generated content.
183
 
184
  ```python
185
- @mcp.tool
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
- **Method signature:**
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
- <VersionBadge version="2.0.0" />
221
 
222
- Request the client's LLM to generate text based on provided messages. This is useful when your function needs to leverage the LLM's capabilities to process data or generate responses.
223
 
224
  ```python
225
- @mcp.tool
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
- **Method signature:**
249
 
250
- - **`ctx.sample(messages: str | list[str | SamplingMessage], system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None) -> TextContent | ImageContent`**
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
- When providing a simple string, it's treated as a user message. For more complex scenarios, you can provide a list of messages with different roles.
259
 
260
  ```python
261
- @mcp.tool
262
- async def generate_example(concept: str, ctx: Context) -> str:
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
- See [Client Sampling](/clients/client#llm-sampling) for more details on how clients handle these requests.
 
 
277
 
278
- ### Component Changes
279
 
280
  <VersionBadge version="2.9.1" />
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
- ### Request Information
 
 
 
 
 
 
 
 
 
 
 
 
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
- Direct use of `session` or `request_context` requires understanding the low-level MCP Python SDK and may be less stable than using the methods provided directly on the `Context` object.
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 ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
 
 
 
 
 
 
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 as _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, data=message, logger=logger_name
 
 
 
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
- if all(isinstance(v, str) for v in values):
258
- return Enum(name, {v.upper(): v for v in values}) # type: ignore[return-value]
259
  return Literal[tuple(values)] # type: ignore[return-value]
260
 
261
 
@@ -399,15 +397,19 @@ def _schema_to_type(
399
 
400
  def _sanitize_name(name: str) -> str:
401
  """Convert string to valid Python identifier."""
 
402
  # Step 1: replace everything except [0-9a-zA-Z_] with underscores
403
  cleaned = re.sub(r"[^0-9a-zA-Z_]", "_", name)
404
  # Step 2: deduplicate underscores
405
  cleaned = re.sub(r"__+", "_", cleaned)
406
- # Step 3: if the first char of original name isn't a letter, prepend field_
407
- if not name or not re.match(r"[a-zA-Z]", name[0]):
408
  cleaned = f"field_{cleaned}"
409
- # Step 4: deduplicate again and strip trailing underscores
410
- cleaned = re.sub(r"__+", "_", cleaned).strip("_")
 
 
 
411
  return cleaned
412
 
413
 
 
41
  from copy import deepcopy
42
  from dataclasses import MISSING, field, make_dataclass
43
  from datetime import datetime
 
44
  from typing import (
45
  Annotated,
46
  Any,
 
253
 
254
  def _create_enum(name: str, values: list[Any]) -> type:
255
  """Create enum type from list of values."""
256
+ # Always return Literal for enum fields to preserve the literal nature
 
257
  return Literal[tuple(values)] # type: ignore[return-value]
258
 
259
 
 
397
 
398
  def _sanitize_name(name: str) -> str:
399
  """Convert string to valid Python identifier."""
400
+ original_name = name
401
  # Step 1: replace everything except [0-9a-zA-Z_] with underscores
402
  cleaned = re.sub(r"[^0-9a-zA-Z_]", "_", name)
403
  # Step 2: deduplicate underscores
404
  cleaned = re.sub(r"__+", "_", cleaned)
405
+ # Step 3: if the first char of original name isn't a letter or underscore, prepend field_
406
+ if not name or not re.match(r"[a-zA-Z_]", name[0]):
407
  cleaned = f"field_{cleaned}"
408
+ # Step 4: deduplicate again
409
+ cleaned = re.sub(r"__+", "_", cleaned)
410
+ # Step 5: only strip trailing underscores if they weren't in the original name
411
+ if not original_name.endswith("_"):
412
+ cleaned = cleaned.rstrip("_")
413
  return cleaned
414
 
415
 
tests/client/test_elicitation.py 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 typing import Any, Union
 
3
 
4
  import pytest
5
  from pydantic import AnyUrl, BaseModel, TypeAdapter, ValidationError
@@ -106,6 +108,65 @@ class TestSimpleTypes:
106
  validator.validate_python(False)
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  class TestStringConstraints:
110
  """Test suite for string constraint validation."""
111
 
@@ -386,6 +447,29 @@ class TestObjectTypes:
386
  with pytest.raises(ValidationError):
387
  validator.validate_python({"user": {"age": 30}})
388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
 
390
  class TestDefaultValues:
391
  """Test suite for default value handling."""
 
1
+ from dataclasses import dataclass
2
  from datetime import datetime
3
+ from enum import Enum
4
+ from typing import Any, Literal, Union
5
 
6
  import pytest
7
  from pydantic import AnyUrl, BaseModel, TypeAdapter, ValidationError
 
108
  validator.validate_python(False)
109
 
110
 
111
+ class TestConstrainedTypes:
112
+ def test_constant(self):
113
+ validator = TypeAdapter(Literal["x"])
114
+ schema = validator.json_schema()
115
+ type_ = json_schema_to_type(schema)
116
+ assert type_ == Literal["x"]
117
+ assert TypeAdapter(type_).validate_python("x") == "x"
118
+ with pytest.raises(ValidationError):
119
+ TypeAdapter(type_).validate_python("y")
120
+
121
+ def test_union_constants(self):
122
+ validator = TypeAdapter(Literal["x"] | Literal["y"])
123
+ schema = validator.json_schema()
124
+ type_ = json_schema_to_type(schema)
125
+ assert type_ == Literal["x"] | Literal["y"]
126
+ assert TypeAdapter(type_).validate_python("x") == "x"
127
+ assert TypeAdapter(type_).validate_python("y") == "y"
128
+ with pytest.raises(ValidationError):
129
+ TypeAdapter(type_).validate_python("z")
130
+
131
+ def test_enum_str(self):
132
+ class MyEnum(Enum):
133
+ X = "x"
134
+ Y = "y"
135
+
136
+ validator = TypeAdapter(MyEnum)
137
+ schema = validator.json_schema()
138
+ type_ = json_schema_to_type(schema)
139
+ assert type_ == Literal["x", "y"]
140
+ assert TypeAdapter(type_).validate_python("x") == "x"
141
+ assert TypeAdapter(type_).validate_python("y") == "y"
142
+ with pytest.raises(ValidationError):
143
+ TypeAdapter(type_).validate_python("z")
144
+
145
+ def test_enum_int(self):
146
+ class MyEnum(Enum):
147
+ X = 1
148
+ Y = 2
149
+
150
+ validator = TypeAdapter(MyEnum)
151
+ schema = validator.json_schema()
152
+ type_ = json_schema_to_type(schema)
153
+ assert type_ == Literal[1, 2]
154
+ assert TypeAdapter(type_).validate_python(1) == 1
155
+ assert TypeAdapter(type_).validate_python(2) == 2
156
+ with pytest.raises(ValidationError):
157
+ TypeAdapter(type_).validate_python(3)
158
+
159
+ def test_choice(self):
160
+ validator = TypeAdapter(Literal["x", "y"])
161
+ schema = validator.json_schema()
162
+ type_ = json_schema_to_type(schema)
163
+ assert type_ == Literal["x", "y"]
164
+ assert TypeAdapter(type_).validate_python("x") == "x"
165
+ assert TypeAdapter(type_).validate_python("y") == "y"
166
+ with pytest.raises(ValidationError):
167
+ TypeAdapter(type_).validate_python("z")
168
+
169
+
170
  class TestStringConstraints:
171
  """Test suite for string constraint validation."""
172
 
 
447
  with pytest.raises(ValidationError):
448
  validator.validate_python({"user": {"age": 30}})
449
 
450
+ def test_object_with_underscore_names(self):
451
+ @dataclass
452
+ class Data:
453
+ x: int
454
+ x_: int
455
+ _x: int
456
+
457
+ schema = TypeAdapter(Data).json_schema()
458
+ assert schema == {
459
+ "title": "Data",
460
+ "type": "object",
461
+ "properties": {
462
+ "x": {"type": "integer", "title": "X"},
463
+ "x_": {"type": "integer", "title": "X"},
464
+ "_x": {"type": "integer", "title": "X"},
465
+ },
466
+ "required": ["x", "x_", "_x"],
467
+ }
468
+
469
+ object = json_schema_to_type(schema)
470
+ object_schema = TypeAdapter(object).json_schema()
471
+ assert object_schema == schema
472
+
473
 
474
  class TestDefaultValues:
475
  """Test suite for default value handling."""