Merge pull request #910 from jlowin/feature/client-side-prompt-argument-serialization

This commit is contained in:
Jeremiah Lowin 2025-06-22 10:39:07 -04:00 committed by GitHub
commit d12cebce1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 141 additions and 6 deletions

View file

@ -1,4 +1,4 @@
fail_fast: true
fail_fast: false
repos:
- repo: https://github.com/abravalheri/validate-pyproject

View file

@ -234,6 +234,30 @@ The standard client methods return user-friendly representations that may change
* **`list_prompts()`**: Retrieves available prompt templates.
* **`get_prompt(name: str, arguments: dict[str, Any] | None = None)`**: Retrieves a rendered prompt message list.
<VersionBadge version="2.9.0" />
**Automatic Argument Serialization**: When calling prompts with complex arguments, the FastMCP client automatically serializes non-string values to JSON strings as required by the MCP specification. This allows you to pass typed objects directly while maintaining protocol compliance.
```python
from dataclasses import dataclass
@dataclass
class UserData:
name: str
age: int
async with client:
# You can pass complex objects directly
result = await client.get_prompt("analyze_user", {
"user": UserData(name="Alice", age=30), # Automatically serialized to JSON
"preferences": {"theme": "dark"}, # Dict serialized to JSON string
"scores": [85, 92, 78], # List serialized to JSON string
"simple_name": "Bob" # Strings passed through unchanged
})
```
The client handles the serialization automatically using `pydantic_core.to_json()` for consistent formatting, while the server can deserialize these JSON strings back to the expected types if using FastMCP's server-side type conversion.
### Raw MCP Protocol Objects
<VersionBadge version="2.2.7" />

View file

@ -7,6 +7,7 @@ from typing import Any, Generic, Literal, cast, overload
import anyio
import httpx
import mcp.types
import pydantic_core
from exceptiongroup import catch
from mcp import ClientSession
from pydantic import AnyUrl
@ -508,13 +509,13 @@ class Client(Generic[ClientTransportT]):
# --- Prompt ---
async def get_prompt_mcp(
self, name: str, arguments: dict[str, str] | None = None
self, name: str, arguments: dict[str, Any] | None = None
) -> mcp.types.GetPromptResult:
"""Send a prompts/get request and return the complete MCP protocol result.
Args:
name (str): The name of the prompt to retrieve.
arguments (dict[str, str] | None, optional): Arguments to pass to the prompt. Defaults to None.
arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
Returns:
mcp.types.GetPromptResult: The complete response object from the protocol,
@ -523,17 +524,32 @@ class Client(Generic[ClientTransportT]):
Raises:
RuntimeError: If called while the client is not connected.
"""
result = await self.session.get_prompt(name=name, arguments=arguments)
# Serialize arguments for MCP protocol - convert non-string values to JSON
serialized_arguments: dict[str, str] | None = None
if arguments:
serialized_arguments = {}
for key, value in arguments.items():
if isinstance(value, str):
serialized_arguments[key] = value
else:
# Use pydantic_core.to_json for consistent serialization
serialized_arguments[key] = pydantic_core.to_json(value).decode(
"utf-8"
)
result = await self.session.get_prompt(
name=name, arguments=serialized_arguments
)
return result
async def get_prompt(
self, name: str, arguments: dict[str, str] | None = None
self, name: str, arguments: dict[str, Any] | None = None
) -> mcp.types.GetPromptResult:
"""Retrieve a rendered prompt message list from the server.
Args:
name (str): The name of the prompt to retrieve.
arguments (dict[str, str] | None, optional): Arguments to pass to the prompt. Defaults to None.
arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
Returns:
mcp.types.GetPromptResult: The complete response object from the protocol,

View file

@ -220,6 +220,101 @@ async def test_get_prompt_mcp(fastmcp_server):
assert result.description == "Example greeting prompt."
async def test_client_serializes_all_non_string_arguments():
"""Test that client always serializes non-string arguments to JSON, regardless of server types."""
server = FastMCP("TestServer")
@server.prompt
def echo_args(arg1: str, arg2: str, arg3: str) -> str:
"""Server accepts all string args but client sends mixed types."""
return f"arg1: {arg1}, arg2: {arg2}, arg3: {arg3}"
client = Client(transport=FastMCPTransport(server))
async with client:
result = await client.get_prompt(
"echo_args",
{
"arg1": "hello", # string - should pass through
"arg2": [1, 2, 3], # list - should be JSON serialized
"arg3": {"key": "value"}, # dict - should be JSON serialized
},
)
content = result.messages[0].content.text # type: ignore[attr-defined]
assert "arg1: hello" in content
assert "arg2: [1,2,3]" in content # JSON serialized list
assert 'arg3: {"key":"value"}' in content # JSON serialized dict
async def test_client_server_type_conversion_integration():
"""Test that client serialization works with server-side type conversion."""
server = FastMCP("TestServer")
@server.prompt
def typed_prompt(numbers: list[int], config: dict[str, str]) -> str:
"""Server expects typed args - will convert from JSON strings."""
return f"Got {len(numbers)} numbers and {len(config)} config items"
client = Client(transport=FastMCPTransport(server))
async with client:
result = await client.get_prompt(
"typed_prompt",
{"numbers": [1, 2, 3, 4], "config": {"theme": "dark", "lang": "en"}},
)
content = result.messages[0].content.text # type: ignore[attr-defined]
assert "Got 4 numbers and 2 config items" in content
async def test_client_serialization_error():
"""Test client error when object cannot be serialized."""
import pydantic_core
server = FastMCP("TestServer")
@server.prompt
def any_prompt(data: str) -> str:
return f"Got: {data}"
# Create an unserializable object
class UnserializableClass:
def __init__(self):
self.func = lambda x: x # functions can't be JSON serialized
client = Client(transport=FastMCPTransport(server))
async with client:
with pytest.raises(
pydantic_core.PydanticSerializationError, match="Unable to serialize"
):
await client.get_prompt("any_prompt", {"data": UnserializableClass()})
async def test_server_deserialization_error():
"""Test server error when JSON string cannot be converted to expected type."""
from mcp import McpError
server = FastMCP("TestServer")
@server.prompt
def strict_typed_prompt(numbers: list[int]) -> str:
"""Expects list of integers but will receive invalid JSON."""
return f"Got {len(numbers)} numbers"
client = Client(transport=FastMCPTransport(server))
async with client:
with pytest.raises(McpError, match="Error rendering prompt"):
await client.get_prompt(
"strict_typed_prompt",
{
"numbers": "not valid json" # This will fail server-side conversion
},
)
async def test_read_resource_invalid_uri(fastmcp_server):
"""Test reading a resource with an invalid URI."""
client = Client(transport=FastMCPTransport(fastmcp_server))