Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
dbf9272
1
Parent(s): 4e5dae3
Support "no response" elicitation requests
Browse files
docs/clients/elicitation.mdx
CHANGED
|
@@ -65,7 +65,7 @@ The elicitation handler receives four parameters:
|
|
| 65 |
</ResponseField>
|
| 66 |
|
| 67 |
<ResponseField name="response_type" type="type">
|
| 68 |
-
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.
|
| 69 |
</ResponseField>
|
| 70 |
|
| 71 |
<ResponseField name="params" type="ElicitRequestParams">
|
|
|
|
| 65 |
</ResponseField>
|
| 66 |
|
| 67 |
<ResponseField name="response_type" type="type">
|
| 68 |
+
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. If the server requests an empty object (indicating no response), this will be `None`.
|
| 69 |
</ResponseField>
|
| 70 |
|
| 71 |
<ResponseField name="params" type="ElicitRequestParams">
|
docs/servers/elicitation.mdx
CHANGED
|
@@ -68,8 +68,8 @@ async def collect_user_info(ctx: Context) -> str:
|
|
| 68 |
The prompt message to display to the user
|
| 69 |
</ResponseField>
|
| 70 |
|
| 71 |
-
<ResponseField name="response_type" type="type" default="
|
| 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 |
|
|
@@ -140,7 +140,7 @@ The server must send a schema to the client indicating the type of data it expec
|
|
| 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
|
|
@@ -184,6 +184,22 @@ async def pick_a_boolean(ctx: Context) -> str:
|
|
| 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.
|
|
|
|
| 68 |
The prompt message to display to the user
|
| 69 |
</ResponseField>
|
| 70 |
|
| 71 |
+
<ResponseField name="response_type" type="type" default="None">
|
| 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 |
|
|
|
|
| 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`) or no response at all, by automatically wrapping them in MCP-compatible object schemas.
|
| 144 |
|
| 145 |
|
| 146 |
### Scalar Types
|
|
|
|
| 184 |
```
|
| 185 |
</CodeGroup>
|
| 186 |
|
| 187 |
+
### No Response
|
| 188 |
+
|
| 189 |
+
Sometimes, the goal of an elicitation is to simply get a user to approve or reject an action. In this case, you can pass `None` as the response type to indicate that no response is expected. In order to comply with the MCP spec, the client will see a schema requesting an empty object in response. In this case, the `data` field of the `ElicitationResult` object will be `None` when the user accepts the elicitation.
|
| 190 |
+
|
| 191 |
+
```python {4} title="No response"
|
| 192 |
+
@mcp.tool
|
| 193 |
+
async def approve_action(ctx: Context) -> str:
|
| 194 |
+
"""Approve an action."""
|
| 195 |
+
result = await ctx.elicit("Approve this action?", response_type=None)
|
| 196 |
+
|
| 197 |
+
if result.action == "accept":
|
| 198 |
+
return do_action()
|
| 199 |
+
else:
|
| 200 |
+
raise ValueError("Action rejected")
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
### Constrained Options
|
| 204 |
|
| 205 |
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.
|
src/fastmcp/client/elicitation.py
CHANGED
|
@@ -41,7 +41,10 @@ def create_elicitation_callback(
|
|
| 41 |
params: ElicitRequestParams,
|
| 42 |
) -> MCPElicitResult | mcp.types.ErrorData:
|
| 43 |
try:
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
result = await elicitation_handler(
|
| 47 |
params.message, response_type, params, context
|
|
|
|
| 41 |
params: ElicitRequestParams,
|
| 42 |
) -> MCPElicitResult | mcp.types.ErrorData:
|
| 43 |
try:
|
| 44 |
+
if params.requestedSchema == {"type": "object", "properties": {}}:
|
| 45 |
+
response_type = None
|
| 46 |
+
else:
|
| 47 |
+
response_type = json_schema_to_type(params.requestedSchema)
|
| 48 |
|
| 49 |
result = await elicitation_handler(
|
| 50 |
params.message, response_type, params, context
|
src/fastmcp/server/context.py
CHANGED
|
@@ -7,7 +7,7 @@ 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
|
|
@@ -312,11 +312,49 @@ class Context:
|
|
| 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 |
-
) ->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
"""
|
| 321 |
Send an elicitation request to the client and await the response.
|
| 322 |
|
|
@@ -330,6 +368,10 @@ class Context:
|
|
| 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
|
|
@@ -337,48 +379,56 @@ class Context:
|
|
| 337 |
object schema with a single "value" field will be generated.
|
| 338 |
"""
|
| 339 |
if response_type is None:
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
|
| 366 |
result = await self.session.elicit(
|
| 367 |
message=message,
|
| 368 |
-
requestedSchema=
|
| 369 |
related_request_id=self.request_id,
|
| 370 |
)
|
| 371 |
|
| 372 |
-
if result.action == "accept"
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
else:
|
| 381 |
-
return AcceptedElicitation[
|
| 382 |
elif result.action == "decline":
|
| 383 |
return DeclinedElicitation()
|
| 384 |
elif result.action == "cancel":
|
|
|
|
| 7 |
from contextvars import ContextVar, Token
|
| 8 |
from dataclasses import dataclass
|
| 9 |
from enum import Enum
|
| 10 |
+
from typing import Any, Literal, TypeVar, cast, get_origin, overload
|
| 11 |
|
| 12 |
from mcp import LoggingLevel, ServerSession
|
| 13 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
|
|
| 312 |
|
| 313 |
return result.content
|
| 314 |
|
| 315 |
+
@overload
|
| 316 |
+
async def elicit(
|
| 317 |
+
self,
|
| 318 |
+
message: str,
|
| 319 |
+
response_type: None,
|
| 320 |
+
) -> (
|
| 321 |
+
AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation
|
| 322 |
+
): ...
|
| 323 |
+
|
| 324 |
+
"""When response_type is None, the accepted elicitaiton will contain an
|
| 325 |
+
empty dict"""
|
| 326 |
+
|
| 327 |
+
@overload
|
| 328 |
+
async def elicit(
|
| 329 |
+
self,
|
| 330 |
+
message: str,
|
| 331 |
+
response_type: type[T],
|
| 332 |
+
) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ...
|
| 333 |
+
|
| 334 |
+
"""When response_type is not None, the accepted elicitaiton will contain the
|
| 335 |
+
response data"""
|
| 336 |
+
|
| 337 |
+
@overload
|
| 338 |
+
async def elicit(
|
| 339 |
+
self,
|
| 340 |
+
message: str,
|
| 341 |
+
response_type: list[str],
|
| 342 |
+
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
|
| 343 |
+
|
| 344 |
+
"""When response_type is a list of strings, the accepted elicitaiton will
|
| 345 |
+
contain the selected string response"""
|
| 346 |
+
|
| 347 |
async def elicit(
|
| 348 |
self,
|
| 349 |
message: str,
|
| 350 |
response_type: type[T] | list[str] | None = None,
|
| 351 |
+
) -> (
|
| 352 |
+
AcceptedElicitation[T]
|
| 353 |
+
| AcceptedElicitation[dict[str, Any]]
|
| 354 |
+
| AcceptedElicitation[str]
|
| 355 |
+
| DeclinedElicitation
|
| 356 |
+
| CancelledElicitation
|
| 357 |
+
):
|
| 358 |
"""
|
| 359 |
Send an elicitation request to the client and await the response.
|
| 360 |
|
|
|
|
| 368 |
"value" field will be generated for the MCP interaction and
|
| 369 |
automatically deconstructed into the primitive type upon response.
|
| 370 |
|
| 371 |
+
If the response_type is None, the generated schema will be that of an
|
| 372 |
+
empty object in order to comply with the MCP protocol requirements.
|
| 373 |
+
Clients must send an empty object ("{}")in response.
|
| 374 |
+
|
| 375 |
Args:
|
| 376 |
message: A human-readable message explaining what information is needed
|
| 377 |
response_type: The type of the response, which should be a primitive
|
|
|
|
| 379 |
object schema with a single "value" field will be generated.
|
| 380 |
"""
|
| 381 |
if response_type is None:
|
| 382 |
+
schema = {"type": "object", "properties": {}}
|
| 383 |
+
else:
|
| 384 |
+
# if the user provided a list of strings, treat it as a Literal
|
| 385 |
+
if isinstance(response_type, list):
|
| 386 |
+
if not all(isinstance(item, str) for item in response_type):
|
| 387 |
+
raise ValueError(
|
| 388 |
+
"List of options must be a list of strings. Received: "
|
| 389 |
+
f"{response_type}"
|
| 390 |
+
)
|
| 391 |
+
# Convert list of options to Literal type and wrap
|
| 392 |
+
choice_literal = Literal[tuple(response_type)] # type: ignore
|
| 393 |
+
response_type = ScalarElicitationType[choice_literal] # type: ignore
|
| 394 |
+
# if the user provided a primitive scalar, wrap it in an object schema
|
| 395 |
+
elif response_type in {bool, int, float, str}:
|
| 396 |
+
response_type = ScalarElicitationType[response_type] # type: ignore
|
| 397 |
+
# if the user provided a Literal type, wrap it in an object schema
|
| 398 |
+
elif get_origin(response_type) is Literal:
|
| 399 |
+
response_type = ScalarElicitationType[response_type] # type: ignore
|
| 400 |
+
# if the user provided an Enum type, wrap it in an object schema
|
| 401 |
+
elif isinstance(response_type, type) and issubclass(response_type, Enum):
|
| 402 |
+
response_type = ScalarElicitationType[response_type] # type: ignore
|
| 403 |
+
|
| 404 |
+
response_type = cast(type[T], response_type)
|
| 405 |
+
|
| 406 |
+
schema = get_elicitation_schema(response_type)
|
| 407 |
|
| 408 |
result = await self.session.elicit(
|
| 409 |
message=message,
|
| 410 |
+
requestedSchema=schema,
|
| 411 |
related_request_id=self.request_id,
|
| 412 |
)
|
| 413 |
|
| 414 |
+
if result.action == "accept":
|
| 415 |
+
if response_type is not None:
|
| 416 |
+
type_adapter = get_cached_typeadapter(response_type)
|
| 417 |
+
validated_data = cast(
|
| 418 |
+
T | ScalarElicitationType[T],
|
| 419 |
+
type_adapter.validate_python(result.content),
|
| 420 |
+
)
|
| 421 |
+
if isinstance(validated_data, ScalarElicitationType):
|
| 422 |
+
return AcceptedElicitation[T](data=validated_data.value)
|
| 423 |
+
else:
|
| 424 |
+
return AcceptedElicitation[T](data=validated_data)
|
| 425 |
+
elif result.content:
|
| 426 |
+
raise ValueError(
|
| 427 |
+
"Elicitation expected an empty response, but received: "
|
| 428 |
+
f"{result.content}"
|
| 429 |
+
)
|
| 430 |
else:
|
| 431 |
+
return AcceptedElicitation[dict[str, Any]](data={})
|
| 432 |
elif result.action == "decline":
|
| 433 |
return DeclinedElicitation()
|
| 434 |
elif result.action == "cancel":
|
src/fastmcp/server/elicitation.py
CHANGED
|
@@ -81,11 +81,6 @@ def validate_elicitation_json_schema(schema: dict[str, Any]) -> None:
|
|
| 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")
|
|
|
|
| 81 |
)
|
| 82 |
|
| 83 |
properties = schema.get("properties", {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
for prop_name, prop_schema in properties.items():
|
| 86 |
prop_type = prop_schema.get("type")
|
src/fastmcp/utilities/components.py
CHANGED
|
@@ -77,7 +77,3 @@ class FastMCPComponent(FastMCPBaseModel):
|
|
| 77 |
def disable(self) -> None:
|
| 78 |
"""Disable the component."""
|
| 79 |
self.enabled = False
|
| 80 |
-
|
| 81 |
-
def get_display_name(self) -> str:
|
| 82 |
-
"""Get the display name for this component, preferring title over name."""
|
| 83 |
-
return self.title if self.title is not None else self.name
|
|
|
|
| 77 |
def disable(self) -> None:
|
| 78 |
"""Disable the component."""
|
| 79 |
self.enabled = False
|
|
|
|
|
|
|
|
|
|
|
|
tests/client/test_elicitation.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 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 |
|
|
@@ -14,6 +15,7 @@ from fastmcp.server.elicitation import (
|
|
| 14 |
AcceptedElicitation,
|
| 15 |
CancelledElicitation,
|
| 16 |
DeclinedElicitation,
|
|
|
|
| 17 |
)
|
| 18 |
from fastmcp.utilities.types import TypeAdapter
|
| 19 |
|
|
@@ -79,30 +81,6 @@ async def test_elicitation_decline(fastmcp_server):
|
|
| 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")
|
|
@@ -162,6 +140,62 @@ async def test_elicitation_cancel_action():
|
|
| 162 |
|
| 163 |
|
| 164 |
class TestScalarResponseTypes:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
async def test_elicitation_str_response(self):
|
| 166 |
"""Test elicitation with string schema."""
|
| 167 |
mcp = FastMCP("TestServer")
|
|
@@ -262,7 +296,7 @@ class TestScalarResponseTypes:
|
|
| 262 |
result = await client.call_tool("my_tool", {})
|
| 263 |
assert result.data == "x"
|
| 264 |
|
| 265 |
-
async def
|
| 266 |
"""Test elicitation with list schema."""
|
| 267 |
mcp = FastMCP("TestServer")
|
| 268 |
|
|
@@ -459,21 +493,12 @@ async def test_all_primitive_field_types():
|
|
| 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"
|
|
@@ -492,7 +517,6 @@ class TestValidation:
|
|
| 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"
|
|
|
|
| 1 |
from dataclasses import asdict, dataclass
|
| 2 |
from enum import Enum
|
| 3 |
+
from typing import Any, Literal
|
| 4 |
|
| 5 |
import pytest
|
| 6 |
+
from mcp.types import ElicitRequestParams
|
| 7 |
from pydantic import BaseModel
|
| 8 |
from typing_extensions import TypedDict
|
| 9 |
|
|
|
|
| 15 |
AcceptedElicitation,
|
| 16 |
CancelledElicitation,
|
| 17 |
DeclinedElicitation,
|
| 18 |
+
validate_elicitation_json_schema,
|
| 19 |
)
|
| 20 |
from fastmcp.utilities.types import TypeAdapter
|
| 21 |
|
|
|
|
| 81 |
assert result.data == "No name provided."
|
| 82 |
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
async def test_elicitation_handler_parameters():
|
| 85 |
"""Test that elicitation handler receives correct parameters."""
|
| 86 |
mcp = FastMCP("TestServer")
|
|
|
|
| 140 |
|
| 141 |
|
| 142 |
class TestScalarResponseTypes:
|
| 143 |
+
async def test_elicitation_no_response(self):
|
| 144 |
+
"""Test elicitation with no response type."""
|
| 145 |
+
mcp = FastMCP("TestServer")
|
| 146 |
+
|
| 147 |
+
@mcp.tool
|
| 148 |
+
async def my_tool(context: Context) -> None:
|
| 149 |
+
result = await context.elicit(message="", response_type=None)
|
| 150 |
+
return result.data # type: ignore[attr-defined]
|
| 151 |
+
|
| 152 |
+
async def elicitation_handler(
|
| 153 |
+
message, response_type, params: ElicitRequestParams, ctx
|
| 154 |
+
):
|
| 155 |
+
assert params.requestedSchema == {"type": "object", "properties": {}}
|
| 156 |
+
assert response_type == dict[str, Any]
|
| 157 |
+
return ElicitResult(action="accept")
|
| 158 |
+
|
| 159 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 160 |
+
result = await client.call_tool("my_tool", {})
|
| 161 |
+
assert result.data is None
|
| 162 |
+
|
| 163 |
+
async def test_elicitation_empty_response(self):
|
| 164 |
+
"""Test elicitation with empty response type."""
|
| 165 |
+
mcp = FastMCP("TestServer")
|
| 166 |
+
|
| 167 |
+
@mcp.tool
|
| 168 |
+
async def my_tool(context: Context) -> None:
|
| 169 |
+
result = await context.elicit(message="", response_type=None)
|
| 170 |
+
return result.data # type: ignore[attr-defined]
|
| 171 |
+
|
| 172 |
+
async def elicitation_handler(
|
| 173 |
+
message, response_type, params: ElicitRequestParams, ctx
|
| 174 |
+
):
|
| 175 |
+
return ElicitResult(action="accept", content={})
|
| 176 |
+
|
| 177 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 178 |
+
result = await client.call_tool("my_tool", {})
|
| 179 |
+
assert result.data is None
|
| 180 |
+
|
| 181 |
+
async def test_elicitation_response_when_no_response_requested(self):
|
| 182 |
+
"""Test elicitation with no response type."""
|
| 183 |
+
mcp = FastMCP("TestServer")
|
| 184 |
+
|
| 185 |
+
@mcp.tool
|
| 186 |
+
async def my_tool(context: Context) -> None:
|
| 187 |
+
result = await context.elicit(message="", response_type=None)
|
| 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": "hello"})
|
| 192 |
+
|
| 193 |
+
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
| 194 |
+
with pytest.raises(
|
| 195 |
+
ToolError, match="Elicitation expected an empty response"
|
| 196 |
+
):
|
| 197 |
+
await client.call_tool("my_tool", {})
|
| 198 |
+
|
| 199 |
async def test_elicitation_str_response(self):
|
| 200 |
"""Test elicitation with string schema."""
|
| 201 |
mcp = FastMCP("TestServer")
|
|
|
|
| 296 |
result = await client.call_tool("my_tool", {})
|
| 297 |
assert result.data == "x"
|
| 298 |
|
| 299 |
+
async def test_elicitation_list_of_strings_response(self):
|
| 300 |
"""Test elicitation with list schema."""
|
| 301 |
mcp = FastMCP("TestServer")
|
| 302 |
|
|
|
|
| 493 |
class TestValidation:
|
| 494 |
async def test_schema_validation_rejects_non_object(self):
|
| 495 |
"""Test that non-object schemas are rejected."""
|
|
|
|
| 496 |
|
| 497 |
with pytest.raises(TypeError, match="must be an object schema"):
|
| 498 |
validate_elicitation_json_schema({"type": "string"})
|
| 499 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
async def test_schema_validation_rejects_nested_objects(self):
|
| 501 |
"""Test that nested object schemas are rejected."""
|
|
|
|
| 502 |
|
| 503 |
with pytest.raises(
|
| 504 |
TypeError, match="has type 'object' which is not a primitive type"
|
|
|
|
| 517 |
|
| 518 |
async def test_schema_validation_rejects_arrays(self):
|
| 519 |
"""Test that array schemas are rejected."""
|
|
|
|
| 520 |
|
| 521 |
with pytest.raises(
|
| 522 |
TypeError, match="has type 'array' which is not a primitive type"
|