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 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, str] | None = None
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, str] | None, optional): Arguments to pass to the prompt. Defaults to None.
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
- result = await self.session.get_prompt(name=name, arguments=arguments)
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  return result
528
 
529
  async def get_prompt(
530
- self, name: str, arguments: dict[str, str] | None = None
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, str] | None, optional): Arguments to pass to the prompt. Defaults to None.
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))