Spaces:
Running
Running
Merge pull request #908 from jlowin/feature/server-side-type-conversion
Browse files- docs/servers/prompts.mdx +78 -27
- src/fastmcp/prompts/prompt.py +90 -10
- tests/prompts/test_prompt.py +242 -0
- tests/server/test_server_interactions.py +56 -0
docs/servers/prompts.mdx
CHANGED
|
@@ -57,6 +57,84 @@ def generate_code_request(language: str, task_description: str) -> PromptMessage
|
|
| 57 |
Functions with `*args` or `**kwargs` are not supported as prompts. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists.
|
| 58 |
</Tip>
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
### Return Values
|
| 61 |
|
| 62 |
FastMCP intelligently handles different return types from your prompt function:
|
|
@@ -78,33 +156,6 @@ def roleplay_scenario(character: str, situation: str) -> list[Message]:
|
|
| 78 |
]
|
| 79 |
```
|
| 80 |
|
| 81 |
-
### Type Annotations
|
| 82 |
-
|
| 83 |
-
Type annotations are important for prompts. They:
|
| 84 |
-
1. Inform FastMCP about the expected types for each parameter.
|
| 85 |
-
2. Allow validation of parameters received from clients.
|
| 86 |
-
3. Are used to generate the prompt's schema for the MCP protocol.
|
| 87 |
-
|
| 88 |
-
```python
|
| 89 |
-
from pydantic import Field
|
| 90 |
-
from typing import Literal, Optional
|
| 91 |
-
|
| 92 |
-
@mcp.prompt
|
| 93 |
-
def generate_content_request(
|
| 94 |
-
topic: str = Field(description="The main subject to cover"),
|
| 95 |
-
format: Literal["blog", "email", "social"] = "blog",
|
| 96 |
-
tone: str = "professional",
|
| 97 |
-
word_count: Optional[int] = None
|
| 98 |
-
) -> str:
|
| 99 |
-
"""Create a request for generating content in a specific format."""
|
| 100 |
-
prompt = f"Please write a {format} post about {topic} in a {tone} tone."
|
| 101 |
-
|
| 102 |
-
if word_count:
|
| 103 |
-
prompt += f" It should be approximately {word_count} words long."
|
| 104 |
-
|
| 105 |
-
return prompt
|
| 106 |
-
```
|
| 107 |
-
|
| 108 |
|
| 109 |
### Required vs. Optional Parameters
|
| 110 |
|
|
|
|
| 57 |
Functions with `*args` or `**kwargs` are not supported as prompts. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists.
|
| 58 |
</Tip>
|
| 59 |
|
| 60 |
+
### Argument Types
|
| 61 |
+
|
| 62 |
+
<VersionBadge version="2.9.0" />
|
| 63 |
+
|
| 64 |
+
The MCP specification requires that all prompt arguments be passed as strings, but FastMCP allows you to use typed annotations for better developer experience. When you use complex types like `list[int]` or `dict[str, str]`, FastMCP:
|
| 65 |
+
|
| 66 |
+
1. **Automatically converts** string arguments from MCP clients to the expected types
|
| 67 |
+
2. **Generates helpful descriptions** showing the exact JSON string format needed
|
| 68 |
+
3. **Preserves direct usage** - you can still call prompts with properly typed arguments
|
| 69 |
+
|
| 70 |
+
Since the MCP specification only allows string arguments, clients need to know what string format to use for complex types. FastMCP solves this by automatically enhancing the argument descriptions with JSON schema information, making it clear to both humans and LLMs how to format their arguments.
|
| 71 |
+
|
| 72 |
+
<CodeGroup>
|
| 73 |
+
|
| 74 |
+
```python Python Code
|
| 75 |
+
@mcp.prompt
|
| 76 |
+
def analyze_data(
|
| 77 |
+
numbers: list[int],
|
| 78 |
+
metadata: dict[str, str],
|
| 79 |
+
threshold: float
|
| 80 |
+
) -> str:
|
| 81 |
+
"""Analyze numerical data."""
|
| 82 |
+
avg = sum(numbers) / len(numbers)
|
| 83 |
+
return f"Average: {avg}, above threshold: {avg > threshold}"
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
```json Resulting MCP Prompt
|
| 87 |
+
{
|
| 88 |
+
"name": "analyze_data",
|
| 89 |
+
"description": "Analyze numerical data.",
|
| 90 |
+
"arguments": [
|
| 91 |
+
{
|
| 92 |
+
"name": "numbers",
|
| 93 |
+
"description": "Provide as a JSON string matching the following schema: {\"items\":{\"type\":\"integer\"},\"type\":\"array\"}",
|
| 94 |
+
"required": true
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
"name": "metadata",
|
| 98 |
+
"description": "Provide as a JSON string matching the following schema: {\"additionalProperties\":{\"type\":\"string\"},\"type\":\"object\"}",
|
| 99 |
+
"required": true
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"name": "threshold",
|
| 103 |
+
"description": "Provide as a JSON string matching the following schema: {\"type\":\"number\"}",
|
| 104 |
+
"required": true
|
| 105 |
+
}
|
| 106 |
+
]
|
| 107 |
+
}
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
</CodeGroup>
|
| 111 |
+
|
| 112 |
+
**MCP clients will call this prompt with string arguments:**
|
| 113 |
+
```json
|
| 114 |
+
{
|
| 115 |
+
"numbers": "[1, 2, 3, 4, 5]",
|
| 116 |
+
"metadata": "{\"source\": \"api\", \"version\": \"1.0\"}",
|
| 117 |
+
"threshold": "2.5"
|
| 118 |
+
}
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
**But you can still call it directly with proper types:**
|
| 122 |
+
```python
|
| 123 |
+
# This also works for direct calls
|
| 124 |
+
result = await prompt.render({
|
| 125 |
+
"numbers": [1, 2, 3, 4, 5],
|
| 126 |
+
"metadata": {"source": "api", "version": "1.0"},
|
| 127 |
+
"threshold": 2.5
|
| 128 |
+
})
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
<Warning>
|
| 132 |
+
Keep your type annotations simple when using this feature. Complex nested types or custom classes may not convert reliably from JSON strings. The automatically generated schema descriptions are the only guidance users receive about the expected format.
|
| 133 |
+
|
| 134 |
+
Good choices: `list[int]`, `dict[str, str]`, `float`, `bool`
|
| 135 |
+
Avoid: Complex Pydantic models, deeply nested structures, custom classes
|
| 136 |
+
</Warning>
|
| 137 |
+
|
| 138 |
### Return Values
|
| 139 |
|
| 140 |
FastMCP intelligently handles different return types from your prompt function:
|
|
|
|
| 156 |
]
|
| 157 |
```
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
### Required vs. Optional Parameters
|
| 161 |
|
src/fastmcp/prompts/prompt.py
CHANGED
|
@@ -3,15 +3,16 @@
|
|
| 3 |
from __future__ import annotations as _annotations
|
| 4 |
|
| 5 |
import inspect
|
|
|
|
| 6 |
from abc import ABC, abstractmethod
|
| 7 |
from collections.abc import Awaitable, Callable, Sequence
|
| 8 |
-
from typing import
|
| 9 |
|
| 10 |
import pydantic_core
|
| 11 |
from mcp.types import Prompt as MCPPrompt
|
| 12 |
from mcp.types import PromptArgument as MCPPromptArgument
|
| 13 |
from mcp.types import PromptMessage, Role, TextContent
|
| 14 |
-
from pydantic import Field, TypeAdapter
|
| 15 |
|
| 16 |
from fastmcp.exceptions import PromptError
|
| 17 |
from fastmcp.server.dependencies import get_context
|
|
@@ -25,10 +26,6 @@ from fastmcp.utilities.types import (
|
|
| 25 |
get_cached_typeadapter,
|
| 26 |
)
|
| 27 |
|
| 28 |
-
if TYPE_CHECKING:
|
| 29 |
-
pass
|
| 30 |
-
|
| 31 |
-
|
| 32 |
logger = get_logger(__name__)
|
| 33 |
|
| 34 |
|
|
@@ -181,17 +178,43 @@ class FunctionPrompt(Prompt):
|
|
| 181 |
arguments: list[PromptArgument] = []
|
| 182 |
if "properties" in parameters:
|
| 183 |
for param_name, param in parameters["properties"].items():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
arguments.append(
|
| 185 |
PromptArgument(
|
| 186 |
name=param_name,
|
| 187 |
-
description=
|
| 188 |
required=param_name in parameters.get("required", []),
|
| 189 |
)
|
| 190 |
)
|
| 191 |
|
| 192 |
-
# ensure the arguments are properly cast
|
| 193 |
-
fn = validate_call(fn)
|
| 194 |
-
|
| 195 |
return cls(
|
| 196 |
name=func_name,
|
| 197 |
description=description,
|
|
@@ -201,6 +224,60 @@ class FunctionPrompt(Prompt):
|
|
| 201 |
fn=fn,
|
| 202 |
)
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
async def render(
|
| 205 |
self,
|
| 206 |
arguments: dict[str, Any] | None = None,
|
|
@@ -223,6 +300,9 @@ class FunctionPrompt(Prompt):
|
|
| 223 |
if context_kwarg and context_kwarg not in kwargs:
|
| 224 |
kwargs[context_kwarg] = get_context()
|
| 225 |
|
|
|
|
|
|
|
|
|
|
| 226 |
# Call function and check if result is a coroutine
|
| 227 |
result = self.fn(**kwargs)
|
| 228 |
if inspect.iscoroutine(result):
|
|
|
|
| 3 |
from __future__ import annotations as _annotations
|
| 4 |
|
| 5 |
import inspect
|
| 6 |
+
import json
|
| 7 |
from abc import ABC, abstractmethod
|
| 8 |
from collections.abc import Awaitable, Callable, Sequence
|
| 9 |
+
from typing import Any
|
| 10 |
|
| 11 |
import pydantic_core
|
| 12 |
from mcp.types import Prompt as MCPPrompt
|
| 13 |
from mcp.types import PromptArgument as MCPPromptArgument
|
| 14 |
from mcp.types import PromptMessage, Role, TextContent
|
| 15 |
+
from pydantic import Field, TypeAdapter
|
| 16 |
|
| 17 |
from fastmcp.exceptions import PromptError
|
| 18 |
from fastmcp.server.dependencies import get_context
|
|
|
|
| 26 |
get_cached_typeadapter,
|
| 27 |
)
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
logger = get_logger(__name__)
|
| 30 |
|
| 31 |
|
|
|
|
| 178 |
arguments: list[PromptArgument] = []
|
| 179 |
if "properties" in parameters:
|
| 180 |
for param_name, param in parameters["properties"].items():
|
| 181 |
+
arg_description = param.get("description")
|
| 182 |
+
|
| 183 |
+
# For non-string parameters, append JSON schema info to help users
|
| 184 |
+
# understand the expected format when passing as strings (MCP requirement)
|
| 185 |
+
if param_name in sig.parameters:
|
| 186 |
+
sig_param = sig.parameters[param_name]
|
| 187 |
+
if (
|
| 188 |
+
sig_param.annotation != inspect.Parameter.empty
|
| 189 |
+
and sig_param.annotation is not str
|
| 190 |
+
and param_name != context_kwarg
|
| 191 |
+
):
|
| 192 |
+
# Get the JSON schema for this specific parameter type
|
| 193 |
+
try:
|
| 194 |
+
param_adapter = get_cached_typeadapter(sig_param.annotation)
|
| 195 |
+
param_schema = param_adapter.json_schema()
|
| 196 |
+
|
| 197 |
+
# Create compact schema representation
|
| 198 |
+
schema_str = json.dumps(param_schema, separators=(",", ":"))
|
| 199 |
+
|
| 200 |
+
# Append schema info to description
|
| 201 |
+
schema_note = f"Provide as a JSON string matching the following schema: {schema_str}"
|
| 202 |
+
if arg_description:
|
| 203 |
+
arg_description = f"{arg_description}\n\n{schema_note}"
|
| 204 |
+
else:
|
| 205 |
+
arg_description = schema_note
|
| 206 |
+
except Exception:
|
| 207 |
+
# If schema generation fails, skip enhancement
|
| 208 |
+
pass
|
| 209 |
+
|
| 210 |
arguments.append(
|
| 211 |
PromptArgument(
|
| 212 |
name=param_name,
|
| 213 |
+
description=arg_description,
|
| 214 |
required=param_name in parameters.get("required", []),
|
| 215 |
)
|
| 216 |
)
|
| 217 |
|
|
|
|
|
|
|
|
|
|
| 218 |
return cls(
|
| 219 |
name=func_name,
|
| 220 |
description=description,
|
|
|
|
| 224 |
fn=fn,
|
| 225 |
)
|
| 226 |
|
| 227 |
+
def _convert_string_arguments(self, kwargs: dict[str, Any]) -> dict[str, Any]:
|
| 228 |
+
"""Convert string arguments to expected types based on function signature."""
|
| 229 |
+
from fastmcp.server.context import Context
|
| 230 |
+
|
| 231 |
+
sig = inspect.signature(self.fn)
|
| 232 |
+
converted_kwargs = {}
|
| 233 |
+
|
| 234 |
+
# Find context parameter name if any
|
| 235 |
+
context_param_name = find_kwarg_by_type(self.fn, kwarg_type=Context)
|
| 236 |
+
|
| 237 |
+
for param_name, param_value in kwargs.items():
|
| 238 |
+
if param_name in sig.parameters:
|
| 239 |
+
param = sig.parameters[param_name]
|
| 240 |
+
|
| 241 |
+
# Skip Context parameters - they're handled separately
|
| 242 |
+
if param_name == context_param_name:
|
| 243 |
+
converted_kwargs[param_name] = param_value
|
| 244 |
+
continue
|
| 245 |
+
|
| 246 |
+
# If parameter has no annotation or annotation is str, pass as-is
|
| 247 |
+
if (
|
| 248 |
+
param.annotation == inspect.Parameter.empty
|
| 249 |
+
or param.annotation is str
|
| 250 |
+
):
|
| 251 |
+
converted_kwargs[param_name] = param_value
|
| 252 |
+
# If argument is not a string, pass as-is (already properly typed)
|
| 253 |
+
elif not isinstance(param_value, str):
|
| 254 |
+
converted_kwargs[param_name] = param_value
|
| 255 |
+
else:
|
| 256 |
+
# Try to convert string argument using type adapter
|
| 257 |
+
try:
|
| 258 |
+
adapter = get_cached_typeadapter(param.annotation)
|
| 259 |
+
# Try JSON parsing first for complex types
|
| 260 |
+
try:
|
| 261 |
+
converted_kwargs[param_name] = adapter.validate_json(
|
| 262 |
+
param_value
|
| 263 |
+
)
|
| 264 |
+
except (ValueError, TypeError, pydantic_core.ValidationError):
|
| 265 |
+
# Fallback to direct validation
|
| 266 |
+
converted_kwargs[param_name] = adapter.validate_python(
|
| 267 |
+
param_value
|
| 268 |
+
)
|
| 269 |
+
except (ValueError, TypeError, pydantic_core.ValidationError) as e:
|
| 270 |
+
# If conversion fails, provide informative error
|
| 271 |
+
raise PromptError(
|
| 272 |
+
f"Could not convert argument '{param_name}' with value '{param_value}' "
|
| 273 |
+
f"to expected type {param.annotation}. Error: {e}"
|
| 274 |
+
)
|
| 275 |
+
else:
|
| 276 |
+
# Parameter not in function signature, pass as-is
|
| 277 |
+
converted_kwargs[param_name] = param_value
|
| 278 |
+
|
| 279 |
+
return converted_kwargs
|
| 280 |
+
|
| 281 |
async def render(
|
| 282 |
self,
|
| 283 |
arguments: dict[str, Any] | None = None,
|
|
|
|
| 300 |
if context_kwarg and context_kwarg not in kwargs:
|
| 301 |
kwargs[context_kwarg] = get_context()
|
| 302 |
|
| 303 |
+
# Convert string arguments to expected types when needed
|
| 304 |
+
kwargs = self._convert_string_arguments(kwargs)
|
| 305 |
+
|
| 306 |
# Call function and check if result is a coroutine
|
| 307 |
result = self.fn(**kwargs)
|
| 308 |
if inspect.iscoroutine(result):
|
tests/prompts/test_prompt.py
CHANGED
|
@@ -240,3 +240,245 @@ class TestRenderPrompt:
|
|
| 240 |
),
|
| 241 |
)
|
| 242 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
),
|
| 241 |
)
|
| 242 |
]
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
class TestPromptTypeConversion:
|
| 246 |
+
async def test_list_of_integers_as_string_args(self):
|
| 247 |
+
"""Test that prompts can handle complex types passed as strings from MCP spec."""
|
| 248 |
+
|
| 249 |
+
def sum_numbers(numbers: list[int]) -> str:
|
| 250 |
+
"""Calculate the sum of a list of numbers."""
|
| 251 |
+
total = sum(numbers)
|
| 252 |
+
return f"The sum is: {total}"
|
| 253 |
+
|
| 254 |
+
prompt = Prompt.from_function(sum_numbers)
|
| 255 |
+
|
| 256 |
+
# MCP spec only allows string arguments, so this should work
|
| 257 |
+
# after we implement type conversion
|
| 258 |
+
result_from_string = await prompt.render(
|
| 259 |
+
arguments={"numbers": "[1, 2, 3, 4, 5]"}
|
| 260 |
+
)
|
| 261 |
+
assert result_from_string == [
|
| 262 |
+
PromptMessage(
|
| 263 |
+
role="user", content=TextContent(type="text", text="The sum is: 15")
|
| 264 |
+
)
|
| 265 |
+
]
|
| 266 |
+
|
| 267 |
+
# Both should work now with string conversion
|
| 268 |
+
result_from_list_string = await prompt.render(
|
| 269 |
+
arguments={"numbers": "[1, 2, 3, 4, 5]"}
|
| 270 |
+
)
|
| 271 |
+
assert result_from_list_string == result_from_string
|
| 272 |
+
|
| 273 |
+
async def test_various_type_conversions(self):
|
| 274 |
+
"""Test type conversion for various data types."""
|
| 275 |
+
|
| 276 |
+
def process_data(
|
| 277 |
+
name: str,
|
| 278 |
+
age: int,
|
| 279 |
+
scores: list[float],
|
| 280 |
+
metadata: dict[str, str],
|
| 281 |
+
active: bool,
|
| 282 |
+
) -> str:
|
| 283 |
+
return f"{name} ({age}): {len(scores)} scores, active={active}, metadata keys={list(metadata.keys())}"
|
| 284 |
+
|
| 285 |
+
prompt = Prompt.from_function(process_data)
|
| 286 |
+
|
| 287 |
+
# All arguments as strings (as MCP would send them)
|
| 288 |
+
result = await prompt.render(
|
| 289 |
+
arguments={
|
| 290 |
+
"name": "Alice",
|
| 291 |
+
"age": "25",
|
| 292 |
+
"scores": "[1.5, 2.0, 3.5]",
|
| 293 |
+
"metadata": '{"project": "test", "version": "1.0"}',
|
| 294 |
+
"active": "true",
|
| 295 |
+
}
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
expected_text = (
|
| 299 |
+
"Alice (25): 3 scores, active=True, metadata keys=['project', 'version']"
|
| 300 |
+
)
|
| 301 |
+
assert result == [
|
| 302 |
+
PromptMessage(
|
| 303 |
+
role="user", content=TextContent(type="text", text=expected_text)
|
| 304 |
+
)
|
| 305 |
+
]
|
| 306 |
+
|
| 307 |
+
async def test_type_conversion_error_handling(self):
|
| 308 |
+
"""Test that informative errors are raised for invalid type conversions."""
|
| 309 |
+
from fastmcp.exceptions import PromptError
|
| 310 |
+
|
| 311 |
+
def typed_prompt(numbers: list[int]) -> str:
|
| 312 |
+
return f"Got {len(numbers)} numbers"
|
| 313 |
+
|
| 314 |
+
prompt = Prompt.from_function(typed_prompt)
|
| 315 |
+
|
| 316 |
+
# Test with invalid JSON - should raise PromptError due to exception handling in render()
|
| 317 |
+
with pytest.raises(PromptError) as exc_info:
|
| 318 |
+
await prompt.render(arguments={"numbers": "not valid json"})
|
| 319 |
+
|
| 320 |
+
assert f"Error rendering prompt {prompt.name}" in str(exc_info.value)
|
| 321 |
+
|
| 322 |
+
async def test_json_parsing_fallback(self):
|
| 323 |
+
"""Test that JSON parsing falls back to direct validation when needed."""
|
| 324 |
+
|
| 325 |
+
def data_prompt(value: int) -> str:
|
| 326 |
+
return f"Value: {value}"
|
| 327 |
+
|
| 328 |
+
prompt = Prompt.from_function(data_prompt)
|
| 329 |
+
|
| 330 |
+
# This should work with JSON parsing (integer as string)
|
| 331 |
+
result1 = await prompt.render(arguments={"value": "42"})
|
| 332 |
+
assert result1 == [
|
| 333 |
+
PromptMessage(
|
| 334 |
+
role="user", content=TextContent(type="text", text="Value: 42")
|
| 335 |
+
)
|
| 336 |
+
]
|
| 337 |
+
|
| 338 |
+
# This should work with direct validation (already an integer string)
|
| 339 |
+
result2 = await prompt.render(arguments={"value": "123"})
|
| 340 |
+
assert result2 == [
|
| 341 |
+
PromptMessage(
|
| 342 |
+
role="user", content=TextContent(type="text", text="Value: 123")
|
| 343 |
+
)
|
| 344 |
+
]
|
| 345 |
+
|
| 346 |
+
async def test_mixed_string_and_typed_args(self):
|
| 347 |
+
"""Test mixing string args (no conversion) with typed args (conversion needed)."""
|
| 348 |
+
|
| 349 |
+
def mixed_prompt(message: str, count: int) -> str:
|
| 350 |
+
return f"{message} (repeated {count} times)"
|
| 351 |
+
|
| 352 |
+
prompt = Prompt.from_function(mixed_prompt)
|
| 353 |
+
|
| 354 |
+
result = await prompt.render(
|
| 355 |
+
arguments={
|
| 356 |
+
"message": "Hello world", # str - no conversion needed
|
| 357 |
+
"count": "3", # int - conversion needed
|
| 358 |
+
}
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
assert result == [
|
| 362 |
+
PromptMessage(
|
| 363 |
+
role="user",
|
| 364 |
+
content=TextContent(type="text", text="Hello world (repeated 3 times)"),
|
| 365 |
+
)
|
| 366 |
+
]
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
class TestPromptArgumentDescriptions:
|
| 370 |
+
def test_enhanced_descriptions_for_non_string_types(self):
|
| 371 |
+
"""Test that non-string argument types get enhanced descriptions with JSON schema."""
|
| 372 |
+
|
| 373 |
+
def analyze_data(
|
| 374 |
+
name: str,
|
| 375 |
+
numbers: list[int],
|
| 376 |
+
metadata: dict[str, str],
|
| 377 |
+
threshold: float,
|
| 378 |
+
active: bool,
|
| 379 |
+
) -> str:
|
| 380 |
+
"""Analyze numerical data."""
|
| 381 |
+
return f"Analyzed {name}"
|
| 382 |
+
|
| 383 |
+
prompt = Prompt.from_function(analyze_data)
|
| 384 |
+
|
| 385 |
+
assert prompt.arguments is not None
|
| 386 |
+
# Check that string parameter has no schema enhancement
|
| 387 |
+
name_arg = next((arg for arg in prompt.arguments if arg.name == "name"), None)
|
| 388 |
+
assert name_arg is not None
|
| 389 |
+
assert name_arg.description is None # No enhancement for string types
|
| 390 |
+
|
| 391 |
+
# Check that non-string parameters have schema enhancements
|
| 392 |
+
numbers_arg = next(
|
| 393 |
+
(arg for arg in prompt.arguments if arg.name == "numbers"), None
|
| 394 |
+
)
|
| 395 |
+
assert numbers_arg is not None
|
| 396 |
+
assert numbers_arg.description is not None
|
| 397 |
+
assert (
|
| 398 |
+
"Provide as a JSON string matching the following schema:"
|
| 399 |
+
in numbers_arg.description
|
| 400 |
+
)
|
| 401 |
+
assert '{"items":{"type":"integer"},"type":"array"}' in numbers_arg.description
|
| 402 |
+
|
| 403 |
+
metadata_arg = next(
|
| 404 |
+
(arg for arg in prompt.arguments if arg.name == "metadata"), None
|
| 405 |
+
)
|
| 406 |
+
assert metadata_arg is not None
|
| 407 |
+
assert metadata_arg.description is not None
|
| 408 |
+
assert (
|
| 409 |
+
"Provide as a JSON string matching the following schema:"
|
| 410 |
+
in metadata_arg.description
|
| 411 |
+
)
|
| 412 |
+
assert (
|
| 413 |
+
'{"additionalProperties":{"type":"string"},"type":"object"}'
|
| 414 |
+
in metadata_arg.description
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
threshold_arg = next(
|
| 418 |
+
(arg for arg in prompt.arguments if arg.name == "threshold"), None
|
| 419 |
+
)
|
| 420 |
+
assert threshold_arg is not None
|
| 421 |
+
assert threshold_arg.description is not None
|
| 422 |
+
assert (
|
| 423 |
+
"Provide as a JSON string matching the following schema:"
|
| 424 |
+
in threshold_arg.description
|
| 425 |
+
)
|
| 426 |
+
assert '{"type":"number"}' in threshold_arg.description
|
| 427 |
+
|
| 428 |
+
active_arg = next(
|
| 429 |
+
(arg for arg in prompt.arguments if arg.name == "active"), None
|
| 430 |
+
)
|
| 431 |
+
assert active_arg is not None
|
| 432 |
+
assert active_arg.description is not None
|
| 433 |
+
assert (
|
| 434 |
+
"Provide as a JSON string matching the following schema:"
|
| 435 |
+
in active_arg.description
|
| 436 |
+
)
|
| 437 |
+
assert '{"type":"boolean"}' in active_arg.description
|
| 438 |
+
|
| 439 |
+
def test_enhanced_descriptions_with_existing_descriptions(self):
|
| 440 |
+
"""Test that existing parameter descriptions are preserved with schema appended."""
|
| 441 |
+
from typing import Annotated
|
| 442 |
+
|
| 443 |
+
from pydantic import Field
|
| 444 |
+
|
| 445 |
+
def documented_prompt(
|
| 446 |
+
numbers: Annotated[
|
| 447 |
+
list[int], Field(description="A list of integers to process")
|
| 448 |
+
],
|
| 449 |
+
) -> str:
|
| 450 |
+
"""Process numbers."""
|
| 451 |
+
return "processed"
|
| 452 |
+
|
| 453 |
+
prompt = Prompt.from_function(documented_prompt)
|
| 454 |
+
|
| 455 |
+
assert prompt.arguments is not None
|
| 456 |
+
numbers_arg = next(
|
| 457 |
+
(arg for arg in prompt.arguments if arg.name == "numbers"), None
|
| 458 |
+
)
|
| 459 |
+
assert numbers_arg is not None
|
| 460 |
+
# Should have both the original description and the schema
|
| 461 |
+
assert numbers_arg.description is not None
|
| 462 |
+
assert "A list of integers to process" in numbers_arg.description
|
| 463 |
+
assert "\n\n" in numbers_arg.description # Should have newline separator
|
| 464 |
+
assert (
|
| 465 |
+
"Provide as a JSON string matching the following schema:"
|
| 466 |
+
in numbers_arg.description
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
def test_string_parameters_no_enhancement(self):
|
| 470 |
+
"""Test that string parameters don't get schema enhancement."""
|
| 471 |
+
|
| 472 |
+
def string_only_prompt(message: str, name: str) -> str:
|
| 473 |
+
return f"{message}, {name}"
|
| 474 |
+
|
| 475 |
+
prompt = Prompt.from_function(string_only_prompt)
|
| 476 |
+
|
| 477 |
+
assert prompt.arguments is not None
|
| 478 |
+
for arg in prompt.arguments:
|
| 479 |
+
# String parameters should not have schema enhancement
|
| 480 |
+
if arg.description is not None:
|
| 481 |
+
assert (
|
| 482 |
+
"Provide as a JSON string matching the following schema:"
|
| 483 |
+
not in arg.description
|
| 484 |
+
)
|
tests/server/test_server_interactions.py
CHANGED
|
@@ -1785,6 +1785,62 @@ class TestPrompts:
|
|
| 1785 |
assert prompts[0].arguments[1].name == "optional"
|
| 1786 |
assert prompts[0].arguments[1].required is False
|
| 1787 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1788 |
async def test_get_prompt(self):
|
| 1789 |
"""Test getting a prompt through MCP protocol."""
|
| 1790 |
mcp = FastMCP()
|
|
|
|
| 1785 |
assert prompts[0].arguments[1].name == "optional"
|
| 1786 |
assert prompts[0].arguments[1].required is False
|
| 1787 |
|
| 1788 |
+
async def test_list_prompts_with_enhanced_descriptions(self):
|
| 1789 |
+
"""Test that enhanced descriptions with JSON schema are visible via MCP protocol."""
|
| 1790 |
+
mcp = FastMCP()
|
| 1791 |
+
|
| 1792 |
+
@mcp.prompt
|
| 1793 |
+
def analyze_data(
|
| 1794 |
+
name: str, numbers: list[int], metadata: dict[str, str], threshold: float
|
| 1795 |
+
) -> str:
|
| 1796 |
+
"""Analyze some data."""
|
| 1797 |
+
return f"Analyzed {name}"
|
| 1798 |
+
|
| 1799 |
+
async with Client(mcp) as client:
|
| 1800 |
+
prompts = await client.list_prompts()
|
| 1801 |
+
assert len(prompts) == 1
|
| 1802 |
+
prompt = prompts[0]
|
| 1803 |
+
assert prompt.name == "analyze_data"
|
| 1804 |
+
assert prompt.description == "Analyze some data."
|
| 1805 |
+
|
| 1806 |
+
# Find each argument and verify schema enhancements
|
| 1807 |
+
assert prompt.arguments is not None
|
| 1808 |
+
args_by_name = {arg.name: arg for arg in prompt.arguments}
|
| 1809 |
+
|
| 1810 |
+
# String parameter should not have schema enhancement
|
| 1811 |
+
name_arg = args_by_name["name"]
|
| 1812 |
+
assert name_arg.description is None
|
| 1813 |
+
|
| 1814 |
+
# Non-string parameters should have schema enhancements
|
| 1815 |
+
numbers_arg = args_by_name["numbers"]
|
| 1816 |
+
assert numbers_arg.description is not None
|
| 1817 |
+
assert (
|
| 1818 |
+
"Provide as a JSON string matching the following schema:"
|
| 1819 |
+
in numbers_arg.description
|
| 1820 |
+
)
|
| 1821 |
+
assert (
|
| 1822 |
+
'{"items":{"type":"integer"},"type":"array"}' in numbers_arg.description
|
| 1823 |
+
)
|
| 1824 |
+
|
| 1825 |
+
metadata_arg = args_by_name["metadata"]
|
| 1826 |
+
assert metadata_arg.description is not None
|
| 1827 |
+
assert (
|
| 1828 |
+
"Provide as a JSON string matching the following schema:"
|
| 1829 |
+
in metadata_arg.description
|
| 1830 |
+
)
|
| 1831 |
+
assert (
|
| 1832 |
+
'{"additionalProperties":{"type":"string"},"type":"object"}'
|
| 1833 |
+
in metadata_arg.description
|
| 1834 |
+
)
|
| 1835 |
+
|
| 1836 |
+
threshold_arg = args_by_name["threshold"]
|
| 1837 |
+
assert threshold_arg.description is not None
|
| 1838 |
+
assert (
|
| 1839 |
+
"Provide as a JSON string matching the following schema:"
|
| 1840 |
+
in threshold_arg.description
|
| 1841 |
+
)
|
| 1842 |
+
assert '{"type":"number"}' in threshold_arg.description
|
| 1843 |
+
|
| 1844 |
async def test_get_prompt(self):
|
| 1845 |
"""Test getting a prompt through MCP protocol."""
|
| 1846 |
mcp = FastMCP()
|