Jeremiah Lowin commited on
Commit
3892c4b
·
unverified ·
2 Parent(s): 7ead95b2aee6b2

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

Browse files
.pre-commit-config.yaml CHANGED
@@ -1,4 +1,4 @@
1
- fail_fast: true
2
 
3
  repos:
4
  - repo: https://github.com/abravalheri/validate-pyproject
 
1
+ fail_fast: false
2
 
3
  repos:
4
  - repo: https://github.com/abravalheri/validate-pyproject
docs/clients/client.mdx CHANGED
@@ -234,6 +234,30 @@ The standard client methods return user-friendly representations that may change
234
  * **`list_prompts()`**: Retrieves available prompt templates.
235
  * **`get_prompt(name: str, arguments: dict[str, Any] | None = None)`**: Retrieves a rendered prompt message list.
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  ### Raw MCP Protocol Objects
238
 
239
  <VersionBadge version="2.2.7" />
 
234
  * **`list_prompts()`**: Retrieves available prompt templates.
235
  * **`get_prompt(name: str, arguments: dict[str, Any] | None = None)`**: Retrieves a rendered prompt message list.
236
 
237
+ <VersionBadge version="2.9.0" />
238
+
239
+ **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.
240
+
241
+ ```python
242
+ from dataclasses import dataclass
243
+
244
+ @dataclass
245
+ class UserData:
246
+ name: str
247
+ age: int
248
+
249
+ async with client:
250
+ # You can pass complex objects directly
251
+ result = await client.get_prompt("analyze_user", {
252
+ "user": UserData(name="Alice", age=30), # Automatically serialized to JSON
253
+ "preferences": {"theme": "dark"}, # Dict serialized to JSON string
254
+ "scores": [85, 92, 78], # List serialized to JSON string
255
+ "simple_name": "Bob" # Strings passed through unchanged
256
+ })
257
+ ```
258
+
259
+ 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.
260
+
261
  ### Raw MCP Protocol Objects
262
 
263
  <VersionBadge version="2.2.7" />
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,32 @@ 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
+ "utf-8"
538
+ )
539
+
540
+ result = await self.session.get_prompt(
541
+ name=name, arguments=serialized_arguments
542
+ )
543
  return result
544
 
545
  async def get_prompt(
546
+ self, name: str, arguments: dict[str, Any] | None = None
547
  ) -> mcp.types.GetPromptResult:
548
  """Retrieve a rendered prompt message list from the server.
549
 
550
  Args:
551
  name (str): The name of the prompt to retrieve.
552
+ arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
553
 
554
  Returns:
555
  mcp.types.GetPromptResult: The complete response object from the protocol,
tests/client/test_client.py CHANGED
@@ -220,6 +220,101 @@ 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(
290
+ pydantic_core.PydanticSerializationError, match="Unable to serialize"
291
+ ):
292
+ await client.get_prompt("any_prompt", {"data": UnserializableClass()})
293
+
294
+
295
+ async def test_server_deserialization_error():
296
+ """Test server error when JSON string cannot be converted to expected type."""
297
+ from mcp import McpError
298
+
299
+ server = FastMCP("TestServer")
300
+
301
+ @server.prompt
302
+ def strict_typed_prompt(numbers: list[int]) -> str:
303
+ """Expects list of integers but will receive invalid JSON."""
304
+ return f"Got {len(numbers)} numbers"
305
+
306
+ client = Client(transport=FastMCPTransport(server))
307
+
308
+ async with client:
309
+ with pytest.raises(McpError, match="Error rendering prompt"):
310
+ await client.get_prompt(
311
+ "strict_typed_prompt",
312
+ {
313
+ "numbers": "not valid json" # This will fail server-side conversion
314
+ },
315
+ )
316
+
317
+
318
  async def test_read_resource_invalid_uri(fastmcp_server):
319
  """Test reading a resource with an invalid URI."""
320
  client = Client(transport=FastMCPTransport(fastmcp_server))