Spaces:
Running
Running
Jeremiah Lowin Claude commited on
Commit ·
da5f976
1
Parent(s): 43369c9
Implement client-side argument serialization with focused tests
Browse files- Add pydantic_core.to_json() serialization for non-string prompt arguments
- Update type annotations to accept dict[str, Any] instead of dict[str, str]
- Add focused tests covering specific scenarios:
* Client always serializes non-string args regardless of server types
* Integration with server-side type conversion
* Client serialization error with specific PydanticSerializationError
* Server deserialization error with specific McpError match
This ensures MCP protocol compliance while maintaining developer experience
with typed arguments.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
- src/fastmcp/client/client.py +19 -5
- tests/client/test_client.py +93 -0
src/fastmcp/client/client.py
CHANGED
|
@@ -7,6 +7,7 @@ from typing import Any, Generic, Literal, cast, overload
|
|
| 7 |
import anyio
|
| 8 |
import httpx
|
| 9 |
import mcp.types
|
|
|
|
| 10 |
from exceptiongroup import catch
|
| 11 |
from mcp import ClientSession
|
| 12 |
from pydantic import AnyUrl
|
|
@@ -508,13 +509,13 @@ class Client(Generic[ClientTransportT]):
|
|
| 508 |
|
| 509 |
# --- Prompt ---
|
| 510 |
async def get_prompt_mcp(
|
| 511 |
-
self, name: str, arguments: dict[str,
|
| 512 |
) -> mcp.types.GetPromptResult:
|
| 513 |
"""Send a prompts/get request and return the complete MCP protocol result.
|
| 514 |
|
| 515 |
Args:
|
| 516 |
name (str): The name of the prompt to retrieve.
|
| 517 |
-
arguments (dict[str,
|
| 518 |
|
| 519 |
Returns:
|
| 520 |
mcp.types.GetPromptResult: The complete response object from the protocol,
|
|
@@ -523,17 +524,30 @@ class Client(Generic[ClientTransportT]):
|
|
| 523 |
Raises:
|
| 524 |
RuntimeError: If called while the client is not connected.
|
| 525 |
"""
|
| 526 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
return result
|
| 528 |
|
| 529 |
async def get_prompt(
|
| 530 |
-
self, name: str, arguments: dict[str,
|
| 531 |
) -> mcp.types.GetPromptResult:
|
| 532 |
"""Retrieve a rendered prompt message list from the server.
|
| 533 |
|
| 534 |
Args:
|
| 535 |
name (str): The name of the prompt to retrieve.
|
| 536 |
-
arguments (dict[str,
|
| 537 |
|
| 538 |
Returns:
|
| 539 |
mcp.types.GetPromptResult: The complete response object from the protocol,
|
|
|
|
| 7 |
import anyio
|
| 8 |
import httpx
|
| 9 |
import mcp.types
|
| 10 |
+
import pydantic_core
|
| 11 |
from exceptiongroup import catch
|
| 12 |
from mcp import ClientSession
|
| 13 |
from pydantic import AnyUrl
|
|
|
|
| 509 |
|
| 510 |
# --- Prompt ---
|
| 511 |
async def get_prompt_mcp(
|
| 512 |
+
self, name: str, arguments: dict[str, Any] | None = None
|
| 513 |
) -> mcp.types.GetPromptResult:
|
| 514 |
"""Send a prompts/get request and return the complete MCP protocol result.
|
| 515 |
|
| 516 |
Args:
|
| 517 |
name (str): The name of the prompt to retrieve.
|
| 518 |
+
arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
|
| 519 |
|
| 520 |
Returns:
|
| 521 |
mcp.types.GetPromptResult: The complete response object from the protocol,
|
|
|
|
| 524 |
Raises:
|
| 525 |
RuntimeError: If called while the client is not connected.
|
| 526 |
"""
|
| 527 |
+
# Serialize arguments for MCP protocol - convert non-string values to JSON
|
| 528 |
+
serialized_arguments: dict[str, str] | None = None
|
| 529 |
+
if arguments:
|
| 530 |
+
serialized_arguments = {}
|
| 531 |
+
for key, value in arguments.items():
|
| 532 |
+
if isinstance(value, str):
|
| 533 |
+
serialized_arguments[key] = value
|
| 534 |
+
else:
|
| 535 |
+
# Use pydantic_core.to_json for consistent serialization
|
| 536 |
+
serialized_arguments[key] = pydantic_core.to_json(value).decode()
|
| 537 |
+
|
| 538 |
+
result = await self.session.get_prompt(
|
| 539 |
+
name=name, arguments=serialized_arguments
|
| 540 |
+
)
|
| 541 |
return result
|
| 542 |
|
| 543 |
async def get_prompt(
|
| 544 |
+
self, name: str, arguments: dict[str, Any] | None = None
|
| 545 |
) -> mcp.types.GetPromptResult:
|
| 546 |
"""Retrieve a rendered prompt message list from the server.
|
| 547 |
|
| 548 |
Args:
|
| 549 |
name (str): The name of the prompt to retrieve.
|
| 550 |
+
arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
|
| 551 |
|
| 552 |
Returns:
|
| 553 |
mcp.types.GetPromptResult: The complete response object from the protocol,
|
tests/client/test_client.py
CHANGED
|
@@ -220,6 +220,99 @@ async def test_get_prompt_mcp(fastmcp_server):
|
|
| 220 |
assert result.description == "Example greeting prompt."
|
| 221 |
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
async def test_read_resource_invalid_uri(fastmcp_server):
|
| 224 |
"""Test reading a resource with an invalid URI."""
|
| 225 |
client = Client(transport=FastMCPTransport(fastmcp_server))
|
|
|
|
| 220 |
assert result.description == "Example greeting prompt."
|
| 221 |
|
| 222 |
|
| 223 |
+
async def test_client_serializes_all_non_string_arguments():
|
| 224 |
+
"""Test that client always serializes non-string arguments to JSON, regardless of server types."""
|
| 225 |
+
server = FastMCP("TestServer")
|
| 226 |
+
|
| 227 |
+
@server.prompt
|
| 228 |
+
def echo_args(arg1: str, arg2: str, arg3: str) -> str:
|
| 229 |
+
"""Server accepts all string args but client sends mixed types."""
|
| 230 |
+
return f"arg1: {arg1}, arg2: {arg2}, arg3: {arg3}"
|
| 231 |
+
|
| 232 |
+
client = Client(transport=FastMCPTransport(server))
|
| 233 |
+
|
| 234 |
+
async with client:
|
| 235 |
+
result = await client.get_prompt(
|
| 236 |
+
"echo_args",
|
| 237 |
+
{
|
| 238 |
+
"arg1": "hello", # string - should pass through
|
| 239 |
+
"arg2": [1, 2, 3], # list - should be JSON serialized
|
| 240 |
+
"arg3": {"key": "value"}, # dict - should be JSON serialized
|
| 241 |
+
},
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
content = result.messages[0].content.text # type: ignore[attr-defined]
|
| 245 |
+
assert "arg1: hello" in content
|
| 246 |
+
assert "arg2: [1,2,3]" in content # JSON serialized list
|
| 247 |
+
assert 'arg3: {"key":"value"}' in content # JSON serialized dict
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
async def test_client_server_type_conversion_integration():
|
| 251 |
+
"""Test that client serialization works with server-side type conversion."""
|
| 252 |
+
server = FastMCP("TestServer")
|
| 253 |
+
|
| 254 |
+
@server.prompt
|
| 255 |
+
def typed_prompt(numbers: list[int], config: dict[str, str]) -> str:
|
| 256 |
+
"""Server expects typed args - will convert from JSON strings."""
|
| 257 |
+
return f"Got {len(numbers)} numbers and {len(config)} config items"
|
| 258 |
+
|
| 259 |
+
client = Client(transport=FastMCPTransport(server))
|
| 260 |
+
|
| 261 |
+
async with client:
|
| 262 |
+
result = await client.get_prompt(
|
| 263 |
+
"typed_prompt",
|
| 264 |
+
{"numbers": [1, 2, 3, 4], "config": {"theme": "dark", "lang": "en"}},
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
content = result.messages[0].content.text # type: ignore[attr-defined]
|
| 268 |
+
assert "Got 4 numbers and 2 config items" in content
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
async def test_client_serialization_error():
|
| 272 |
+
"""Test client error when object cannot be serialized."""
|
| 273 |
+
import pydantic_core
|
| 274 |
+
|
| 275 |
+
server = FastMCP("TestServer")
|
| 276 |
+
|
| 277 |
+
@server.prompt
|
| 278 |
+
def any_prompt(data: str) -> str:
|
| 279 |
+
return f"Got: {data}"
|
| 280 |
+
|
| 281 |
+
# Create an unserializable object
|
| 282 |
+
class UnserializableClass:
|
| 283 |
+
def __init__(self):
|
| 284 |
+
self.func = lambda x: x # functions can't be JSON serialized
|
| 285 |
+
|
| 286 |
+
client = Client(transport=FastMCPTransport(server))
|
| 287 |
+
|
| 288 |
+
async with client:
|
| 289 |
+
with pytest.raises(pydantic_core.PydanticSerializationError, match="Unable to serialize"):
|
| 290 |
+
await client.get_prompt("any_prompt", {"data": UnserializableClass()})
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
async def test_server_deserialization_error():
|
| 294 |
+
"""Test server error when JSON string cannot be converted to expected type."""
|
| 295 |
+
from mcp import McpError
|
| 296 |
+
|
| 297 |
+
server = FastMCP("TestServer")
|
| 298 |
+
|
| 299 |
+
@server.prompt
|
| 300 |
+
def strict_typed_prompt(numbers: list[int]) -> str:
|
| 301 |
+
"""Expects list of integers but will receive invalid JSON."""
|
| 302 |
+
return f"Got {len(numbers)} numbers"
|
| 303 |
+
|
| 304 |
+
client = Client(transport=FastMCPTransport(server))
|
| 305 |
+
|
| 306 |
+
async with client:
|
| 307 |
+
with pytest.raises(McpError, match="Error rendering prompt"):
|
| 308 |
+
await client.get_prompt(
|
| 309 |
+
"strict_typed_prompt",
|
| 310 |
+
{
|
| 311 |
+
"numbers": "not valid json" # This will fail server-side conversion
|
| 312 |
+
},
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
async def test_read_resource_invalid_uri(fastmcp_server):
|
| 317 |
"""Test reading a resource with an invalid URI."""
|
| 318 |
client = Client(transport=FastMCPTransport(fastmcp_server))
|