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