mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 21:44:18 +02:00
Fix: Prompt arg handling & docs for #541
Addresses #541: - Server now auto-deserializes JSON string args (list, dict, BaseModel) for prompts. This simplifies server-side prompt logic by reducing boilerplate `json.loads()` calls. - Docs updated to clarify `list_resource_templates` usage for templatized resources. - Docs updated to require client-side `json.dumps()` for complex `get_prompt` arguments, resolving the original Pydantic error. - Adds a new example (`examples/dynamic_story_prompt/`) demonstrating the server-side deserialization benefit and correct client-side serialization. Closes #541. --- Notes for Reviewers: - **Server-Side Auto-Deserialization:** This change introduces a "magic" `json.loads()` in `Prompt.render`. This is an intentional DX improvement. It only triggers for `str` inputs targeting `list`, `dict`, or `BaseModel` type hints. If `json.loads()` fails (e.g., malformed JSON), the original string is passed to Pydantic's `validate_call`, ensuring robust error handling. This avoids boilerplate in user prompt functions. - **Client `get_prompt()` Return Value:** The `examples/dynamic_story_prompt/story_client.py` parses the result of `client.get_prompt()` by iterating and looking for a `('messages', ...)` tuple. This reflects the observed behavior of the current `client.get_prompt()`. This commit does *not* change `client.get_prompt()`'s return behavior; the example merely adapts to it. A separate discussion might be warranted for potentially simplifying `client.get_prompt()`'s return signature in the future.
This commit is contained in:
parent
5aa19c381b
commit
7d2f1f9671
8 changed files with 338 additions and 8 deletions
14
CONCISE_COMMIT_MSG.tmp
Normal file
14
CONCISE_COMMIT_MSG.tmp
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
Fix: Prompt arg handling & docs for #541
|
||||
|
||||
Addresses #541:
|
||||
- Server now auto-deserializes JSON string args (list, dict, BaseModel) for prompts. This simplifies server-side prompt logic by reducing boilerplate `json.loads()` calls.
|
||||
- Docs updated to clarify `list_resource_templates` usage for templatized resources.
|
||||
- Docs updated to require client-side `json.dumps()` for complex `get_prompt` arguments, resolving the original Pydantic error.
|
||||
- Adds a new example (`examples/dynamic_story_prompt/`) demonstrating the server-side deserialization benefit and correct client-side serialization.
|
||||
|
||||
Closes #541.
|
||||
|
||||
---
|
||||
Notes for Reviewers:
|
||||
- **Server-Side Auto-Deserialization:** This change introduces a "magic" `json.loads()` in `Prompt.render`. This is an intentional DX improvement. It only triggers for `str` inputs targeting `list`, `dict`, or `BaseModel` type hints. If `json.loads()` fails (e.g., malformed JSON), the original string is passed to Pydantic's `validate_call`, ensuring robust error handling. This avoids boilerplate in user prompt functions.
|
||||
- **Client `get_prompt()` Return Value:** The `examples/dynamic_story_prompt/story_client.py` parses the result of `client.get_prompt()` by iterating and looking for a `('messages', ...)` tuple. This reflects the observed behavior of the current `client.get_prompt()`. This commit does *not* change `client.get_prompt()`'s return behavior; the example merely adapts to it. A separate discussion might be warranted for potentially simplifying `client.get_prompt()`'s return signature in the future.
|
||||
|
|
@ -229,6 +229,9 @@ The standard client methods return user-friendly representations that may change
|
|||
templates = await client.list_resource_templates()
|
||||
# templates -> list[mcp.types.ResourceTemplate]
|
||||
```
|
||||
<Tip>
|
||||
If you define a resource with placeholders in its URI (e.g., `@mcp.resource("data://{user_id}/profile")`), it is considered a **resource template**. You will find it using `client.list_resource_templates()`. The `client.list_resources()` method is for resources defined with fully static URIs.
|
||||
</Tip>
|
||||
* **`read_resource(uri: str | AnyUrl)`**: Reads the content of a resource or a resolved template.
|
||||
```python
|
||||
# Read a static resource
|
||||
|
|
@ -244,7 +247,20 @@ The standard client methods return user-friendly representations that may change
|
|||
#### Prompt Operations
|
||||
|
||||
* **`list_prompts()`**: Retrieves available prompt templates.
|
||||
* **`get_prompt(name: str, arguments: dict[str, Any] | None = None)`**: Retrieves a rendered prompt message list.
|
||||
* **`get_prompt(name: str, arguments: dict[str, str] | None = None)`**: Retrieves a rendered prompt message list.
|
||||
* **Important:** When passing complex data types (like lists or dictionaries) as values in the `arguments` dictionary, they **must be serialized to JSON strings**. For example, if your prompt expects a list of numbers for an argument named `data_points`, you should pass `{"data_points": json.dumps([1, 2, 3])}`.
|
||||
* The FastMCP server will automatically attempt to deserialize these JSON strings back into appropriate Python objects (lists, dicts, Pydantic models) if your server-side prompt function is type-hinted accordingly. See the server-side [Prompts documentation](/servers/prompts#automatic-deserialization-of-string-arguments) for more details on this server behavior.
|
||||
```python
|
||||
import json
|
||||
|
||||
# Assuming 'analyze_data' prompt expects 'data_points' (list) and 'settings' (dict)
|
||||
args = {
|
||||
"data_points": json.dumps([1.0, 2.5, 3.0]),
|
||||
"settings": json.dumps({"mode": "detailed", "threshold": 0.5})
|
||||
}
|
||||
prompt_messages = await client.get_prompt("analyze_data", args)
|
||||
# prompt_messages -> list[mcp.types.PromptMessage]
|
||||
```
|
||||
|
||||
### Raw MCP Protocol Objects
|
||||
|
||||
|
|
|
|||
|
|
@ -221,4 +221,104 @@ The duplicate behavior options are:
|
|||
- `"warn"` (default): Logs a warning, and the new prompt replaces the old one.
|
||||
- `"error"`: Raises a `ValueError`, preventing the duplicate registration.
|
||||
- `"replace"`: Silently replaces the existing prompt with the new one.
|
||||
- `"ignore"`: Keeps the original prompt and ignores the new registration attempt.
|
||||
- `"ignore"`: Keeps the original prompt and ignores the new registration attempt.
|
||||
|
||||
### Automatic Deserialization of String Arguments
|
||||
|
||||
<VersionBadge version="2.4.1" />
|
||||
|
||||
FastMCP enhances developer experience by automatically handling the deserialization of string arguments for your prompt functions when they are intended to be complex Python types like lists, dictionaries, or Pydantic models.
|
||||
|
||||
When you define your prompt functions, you can use Pythonic type hints to specify the data structures you want to work with directly:
|
||||
|
||||
```python server.py
|
||||
from fastmcp import FastMCP
|
||||
from pydantic import BaseModel
|
||||
|
||||
mcp = FastMCP(name="PromptDemoServer")
|
||||
|
||||
class MyConfig(BaseModel):
|
||||
param_a: str
|
||||
param_b: int
|
||||
is_active: bool = True
|
||||
|
||||
@mcp.prompt()
|
||||
def process_user_data(
|
||||
user_id: int,
|
||||
preferences: list[str],
|
||||
settings: MyConfig,
|
||||
metadata: dict[str, str | int]
|
||||
) -> str:
|
||||
"""
|
||||
Generates a prompt based on processed user data, preferences,
|
||||
settings, and metadata.
|
||||
"""
|
||||
# Inside this function, you directly work with Python objects:
|
||||
# - user_id is an int
|
||||
# - preferences is a list of strings
|
||||
# - settings is an instance of MyConfig
|
||||
# - metadata is a dictionary
|
||||
|
||||
pref_string = ", ".join(preferences)
|
||||
return (
|
||||
f"User {user_id} preferences: {pref_string}. "
|
||||
f"Settings: {settings.param_a} (Active: {settings.is_active}, Value: {settings.param_b}). "
|
||||
f"Metadata count: {len(metadata)}."
|
||||
)
|
||||
```
|
||||
|
||||
**How it Works:**
|
||||
|
||||
If a client sends arguments for `preferences`, `settings`, or `metadata` as JSON strings (which is necessary for complex types due to the underlying MCP specification that expects `dict[str, str]` for prompt arguments – see [Client documentation](/clients/client#prompt-operations)), FastMCP's server-side logic will:
|
||||
|
||||
1. Detect that the incoming argument is a string.
|
||||
2. Check your function's type hint for that parameter (e.g., `list[str]`, `MyConfig`, `dict[str, str | int]`).
|
||||
3. If the type hint is `list`, `dict`, or a Pydantic `BaseModel` (or a generic version like `list[Any]`), FastMCP attempts to parse the string using `json.loads()`.
|
||||
4. **On Success:** Your function receives the deserialized Python object (a `list`, `dict`, or `MyConfig` instance).
|
||||
5. **On `json.JSONDecodeError`:** If the string is not valid JSON, it's passed to your function as-is. Pydantic's standard validation will then take over, likely raising a `ValidationError` if the string doesn't match the expected type (e.g., a string like `"not-a-list"` for a `list` parameter).
|
||||
|
||||
This behavior significantly reduces boilerplate in your prompt functions, as you don't need to manually call `json.loads()` and handle potential errors for every complex parameter.
|
||||
|
||||
<details>
|
||||
<summary>Hypothetical Client Example</summary>
|
||||
|
||||
```python client.py
|
||||
import asyncio
|
||||
import json
|
||||
from fastmcp import Client
|
||||
|
||||
async def run_client():
|
||||
client = Client("server.py") # Or appropriate transport to your server
|
||||
|
||||
async with client:
|
||||
user_prefs = ["notifications", "dark_mode"]
|
||||
user_settings = {"param_a": "profile_v2", "param_b": 42, "is_active": False}
|
||||
user_metadata = {"login_count": 10, "last_ip": "192.168.1.100"}
|
||||
|
||||
client_args = {
|
||||
"user_id": 123, # Simple types are passed directly
|
||||
"preferences": json.dumps(user_prefs),
|
||||
"settings": json.dumps(user_settings),
|
||||
"metadata": json.dumps(user_metadata)
|
||||
}
|
||||
|
||||
print(f"Sending to server: {client_args}")
|
||||
|
||||
try:
|
||||
prompt_messages = await client.get_prompt("process_user_data", client_args)
|
||||
for msg in prompt_messages:
|
||||
print(f"Server responded with role '{msg.role}': {msg.content.text}")
|
||||
except Exception as e:
|
||||
print(f"An error occurred: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_client())
|
||||
```
|
||||
**Expected Client Output (if server responds directly with the generated string):**
|
||||
```
|
||||
# Sending to server: {'user_id': 123, 'preferences': '["notifications", "dark_mode"]', 'settings': '{"param_a": "profile_v2", "param_b": 42, "is_active": false}', 'metadata': '{"login_count": 10, "last_ip": "192.168.1.100"}'}
|
||||
# Server responded with role 'user': User 123 preferences: notifications, dark_mode. Settings: profile_v2 (Active: False, Value: 42). Metadata count: 2.
|
||||
```
|
||||
</details>
|
||||
|
||||
## Advanced Usage
|
||||
37
examples/dynamic_story_prompt/README.md
Normal file
37
examples/dynamic_story_prompt/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Dynamic Story Prompt Generator Example
|
||||
|
||||
This example demonstrates how FastMCP's automatic JSON string deserialization for prompt arguments can simplify server-side prompt logic.
|
||||
|
||||
## Scenario
|
||||
|
||||
We have a FastMCP server with a prompt named `generate_dynamic_story_prompt`. This prompt is designed to create an engaging story starter based on several complex inputs:
|
||||
|
||||
1. **Character Details**: Information about the main character, structured as a Pydantic model (name, archetype, quirky trait).
|
||||
2. **Mysterious Objects**: A list of unusual items the character stumbles upon.
|
||||
3. **Active World Laws**: A dictionary describing peculiar rules or conditions currently affecting the story world.
|
||||
|
||||
The server-side prompt function takes these as a `Character` object, a `list[str]`, and a `dict[str, str]` respectively. It then combines them into a creative text prompt.
|
||||
|
||||
## Motivation for Auto-Deserialization
|
||||
|
||||
The developer writing the `generate_dynamic_story_prompt` function wants to work with these inputs as native Python objects for clarity and ease of use within their creative logic. They shouldn't need to manually parse JSON strings for each complex argument.
|
||||
|
||||
The client application (e.g., a web UI, another script) will gather this information and serialize the complex parts (character details, list of objects, world laws dictionary) into JSON strings before sending them to the FastMCP server.
|
||||
|
||||
FastMCP's auto-deserialization feature (for `list`, `dict`, and Pydantic `BaseModel` arguments) bridges this gap:
|
||||
- The client sends complex data as JSON strings (as required by the MCP spec for prompt arguments: `dict[str, str]`).
|
||||
- The server-side `FastMCP` prompt automatically attempts to `json.loads()` these strings into the Python types hinted in the prompt function signature.
|
||||
|
||||
This keeps the server-side prompt function clean, Pythonic, and focused on its core task of generating the story prompt, without boilerplate `json.loads()` calls.
|
||||
|
||||
## Files
|
||||
|
||||
* `story_server.py`: The FastMCP server code defining the `Character` model and the `generate_dynamic_story_prompt`.
|
||||
* `story_client.py`: A conceptual Python client script showing how to call this prompt, including serializing complex arguments to JSON strings.
|
||||
|
||||
## How to Run (Conceptual)
|
||||
|
||||
1. Run the `story_server.py`.
|
||||
2. In a separate terminal, run the `story_client.py`.
|
||||
|
||||
The client will output the creative story prompt generated by the server.
|
||||
96
examples/dynamic_story_prompt/story_client.py
Normal file
96
examples/dynamic_story_prompt/story_client.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import asyncio
|
||||
import json
|
||||
|
||||
from fastmcp import Client
|
||||
|
||||
SERVER_SCRIPT_PATH = __file__.replace("client", "server")
|
||||
|
||||
|
||||
async def main():
|
||||
print(f"Attempting to connect to server script: {SERVER_SCRIPT_PATH}\n")
|
||||
client = Client(SERVER_SCRIPT_PATH)
|
||||
|
||||
async with client:
|
||||
print("Successfully connected to server!")
|
||||
|
||||
# 1. Define the complex data for the prompt
|
||||
character_data = {
|
||||
"name": "Elara",
|
||||
"archetype": "Reluctant Oracle",
|
||||
"quirky_trait": "habit of humming ancient, forgotten tunes when nervous",
|
||||
}
|
||||
|
||||
objects_data = [
|
||||
"a tarnished silver locket that refuses to open",
|
||||
"a smooth, obsidian sphere that whispers secrets in the dark",
|
||||
"a single, petrified rose that blooms only in moonlight",
|
||||
]
|
||||
|
||||
laws_data = {
|
||||
"time": "flows like molasses uphill on Tuesdays",
|
||||
"shadows": "have a mind of their own and occasionally steal small, shiny objects",
|
||||
"laughter": "can briefly mend broken things",
|
||||
}
|
||||
|
||||
# 2. Prepare arguments for the client, serializing complex types to JSON strings
|
||||
prompt_args = {
|
||||
# 'character_details' expects a Character Pydantic model
|
||||
"character_details": json.dumps(character_data),
|
||||
# 'mysterious_objects' expects a list[str]
|
||||
"mysterious_objects": json.dumps(objects_data),
|
||||
# 'world_laws' expects a dict[str, str]
|
||||
"world_laws": json.dumps(laws_data),
|
||||
}
|
||||
|
||||
print("--- Sending to server: ---")
|
||||
for key, value in prompt_args.items():
|
||||
print(f" {key}: {value}")
|
||||
print("--------------------------\n")
|
||||
|
||||
try:
|
||||
# 3. Call the prompt
|
||||
results_iterable = await client.get_prompt(
|
||||
"generate_dynamic_story_prompt", arguments=prompt_args
|
||||
)
|
||||
|
||||
print("--- Generated Story Prompt from Server: ---")
|
||||
|
||||
# The client.get_prompt() seems to return an iterable of (key, value) pairs
|
||||
# from the GetPromptResult model. We need to find the 'messages' key.
|
||||
prompt_messages_list = None
|
||||
if results_iterable:
|
||||
for key, value in results_iterable:
|
||||
if key == "messages":
|
||||
prompt_messages_list = value
|
||||
break # Found the messages list
|
||||
|
||||
if prompt_messages_list:
|
||||
for message in (
|
||||
prompt_messages_list
|
||||
): # This should be a list of PromptMessage objects
|
||||
if (
|
||||
hasattr(message, "content")
|
||||
and hasattr(message.content, "text")
|
||||
and message.content.text is not None
|
||||
):
|
||||
print(message.content.text)
|
||||
else:
|
||||
print(
|
||||
f"(Received message with unexpected content structure: {message!r})"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"(Could not find 'messages' in the prompt result or result was empty)"
|
||||
)
|
||||
|
||||
print("-----------------------------------------")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error calling prompt: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
69
examples/dynamic_story_prompt/story_server.py
Normal file
69
examples/dynamic_story_prompt/story_server.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP(name="DynamicStoryServer")
|
||||
|
||||
|
||||
class Character(BaseModel):
|
||||
name: str = Field(..., description="The character's name.")
|
||||
archetype: str = Field(
|
||||
default="Mysterious Stranger",
|
||||
description="The character's archetype (e.g., Brave Knight, Wily Rogue).",
|
||||
)
|
||||
quirky_trait: str = Field(
|
||||
..., description="A unique or unusual trait of the character."
|
||||
)
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
def generate_dynamic_story_prompt(
|
||||
character_details: Character,
|
||||
mysterious_objects: list[str],
|
||||
world_laws: dict[str, str],
|
||||
) -> str:
|
||||
"""Generates a creative story prompt based on character, objects, and world laws."""
|
||||
|
||||
# Directly use the deserialized Python objects:
|
||||
prompt = f"Our protagonist, {character_details.name}, a self-proclaimed '{character_details.archetype}', "
|
||||
prompt += f"known for their {character_details.quirky_trait}, stumbles upon a peculiar collection: "
|
||||
|
||||
if mysterious_objects:
|
||||
if len(mysterious_objects) == 1:
|
||||
prompt += f"a single {mysterious_objects[0]}. "
|
||||
else:
|
||||
objects_str = (
|
||||
", ".join(mysterious_objects[:-1]) + f", and a {mysterious_objects[-1]}"
|
||||
)
|
||||
prompt += f"{objects_str}. "
|
||||
else:
|
||||
prompt += "nothing but dust bunnies. "
|
||||
|
||||
prompt += (
|
||||
"\n\nSuddenly, the world shifts. The very laws of reality seem to be in flux:"
|
||||
)
|
||||
|
||||
if world_laws:
|
||||
for law, description in world_laws.items():
|
||||
prompt += f"\n- {law.capitalize()}: {description}."
|
||||
else:
|
||||
prompt += "\n- Everything is disconcertingly normal."
|
||||
|
||||
prompt += "\n\nWhat happens next?"
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Starting Dynamic Story Server...")
|
||||
# To run this server, you would typically call mcp.run()
|
||||
# For example, if you have uvicorn and want to run it as an ASGI app (if FastMCP supports it directly or via an adapter):
|
||||
# import uvicorn
|
||||
# uvicorn.run(mcp.asgi_app, host="0.0.0.0", port=8000)
|
||||
# Or, if it runs via its own stdio mechanism, just mcp.run()
|
||||
try:
|
||||
mcp.run() # Assuming this is the standard way to run a FastMCP stdio server
|
||||
except KeyboardInterrupt:
|
||||
print("Server shutting down.")
|
||||
except Exception as e:
|
||||
print(f"Server failed to start or run: {e}")
|
||||
5
justfile
5
justfile
|
|
@ -6,4 +6,7 @@ test: build
|
|||
|
||||
# Run pyright on all files
|
||||
typecheck:
|
||||
uv run --frozen pyright
|
||||
uv run --frozen pyright
|
||||
|
||||
docs:
|
||||
cd docs && uv run mintlify dev
|
||||
|
|
@ -191,7 +191,6 @@ class Prompt(BaseModel):
|
|||
# Prepare arguments
|
||||
kwargs = arguments.copy() if arguments else {}
|
||||
|
||||
# <<< NEW: Attempt to deserialize JSON strings for complex types >>>
|
||||
if self._original_param_types:
|
||||
for param_name, param_value in list(kwargs.items()):
|
||||
if param_name in self._original_param_types and isinstance(
|
||||
|
|
@ -199,9 +198,7 @@ class Prompt(BaseModel):
|
|||
):
|
||||
target_type_hint = self._original_param_types[param_name]
|
||||
|
||||
# Determine the actual base type to check (e.g., list from list[float])
|
||||
origin_type = get_origin(target_type_hint)
|
||||
# Fallback to the hint itself if no origin (e.g. for non-generic BaseModel)
|
||||
type_to_check_for_complex = (
|
||||
origin_type if origin_type else target_type_hint
|
||||
)
|
||||
|
|
@ -212,7 +209,6 @@ class Prompt(BaseModel):
|
|||
elif inspect.isclass(type_to_check_for_complex) and issubclass(
|
||||
type_to_check_for_complex, BaseModel
|
||||
):
|
||||
# BaseModel itself is imported from pydantic, so this check is fine
|
||||
is_json_candidate = True
|
||||
|
||||
if is_json_candidate:
|
||||
|
|
@ -227,7 +223,6 @@ class Prompt(BaseModel):
|
|||
f"FastMCP: Param '{param_name}' for prompt '{self.name}' is a string "
|
||||
"but not valid JSON. Passing as string to Pydantic validation."
|
||||
)
|
||||
# <<< END NEW LOGIC >>>
|
||||
|
||||
# Prepare arguments with context
|
||||
context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue