Jeremiah Lowin Claude commited on
Commit
f574078
·
1 Parent(s): 85ac398

Fix prompt argument type annotation to support mixed typing

Browse files

Updated FunctionPrompt.render() to accept dict[str, Any] instead of
dict[str, str | Context] to preserve the developer experience of
passing properly typed arguments while also supporting string-only
arguments from MCP clients.

The _convert_string_arguments method now intelligently handles both
scenarios:
- Already-typed arguments are passed through unchanged
- String arguments are converted to expected types when needed

This maintains backward compatibility while enabling MCP spec compliance.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

src/fastmcp/prompts/prompt.py CHANGED
@@ -5,13 +5,13 @@ from __future__ import annotations as _annotations
5
  import inspect
6
  from abc import ABC, abstractmethod
7
  from collections.abc import Awaitable, Callable, Sequence
8
- from typing import TYPE_CHECKING, Any
9
 
10
  import pydantic_core
11
  from mcp.types import Prompt as MCPPrompt
12
  from mcp.types import PromptArgument as MCPPromptArgument
13
  from mcp.types import PromptMessage, Role, TextContent
14
- from pydantic import Field, TypeAdapter, validate_call
15
 
16
  from fastmcp.exceptions import PromptError
17
  from fastmcp.server.dependencies import get_context
@@ -25,10 +25,6 @@ from fastmcp.utilities.types import (
25
  get_cached_typeadapter,
26
  )
27
 
28
- if TYPE_CHECKING:
29
- pass
30
-
31
-
32
  logger = get_logger(__name__)
33
 
34
 
@@ -189,8 +185,7 @@ class FunctionPrompt(Prompt):
189
  )
190
  )
191
 
192
- # ensure the arguments are properly cast
193
- fn = validate_call(fn)
194
 
195
  return cls(
196
  name=func_name,
@@ -201,6 +196,60 @@ class FunctionPrompt(Prompt):
201
  fn=fn,
202
  )
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  async def render(
205
  self,
206
  arguments: dict[str, Any] | None = None,
@@ -223,6 +272,9 @@ class FunctionPrompt(Prompt):
223
  if context_kwarg and context_kwarg not in kwargs:
224
  kwargs[context_kwarg] = get_context()
225
 
 
 
 
226
  # Call function and check if result is a coroutine
227
  result = self.fn(**kwargs)
228
  if inspect.iscoroutine(result):
 
5
  import inspect
6
  from abc import ABC, abstractmethod
7
  from collections.abc import Awaitable, Callable, Sequence
8
+ from typing import Any
9
 
10
  import pydantic_core
11
  from mcp.types import Prompt as MCPPrompt
12
  from mcp.types import PromptArgument as MCPPromptArgument
13
  from mcp.types import PromptMessage, Role, TextContent
14
+ from pydantic import Field, TypeAdapter
15
 
16
  from fastmcp.exceptions import PromptError
17
  from fastmcp.server.dependencies import get_context
 
25
  get_cached_typeadapter,
26
  )
27
 
 
 
 
 
28
  logger = get_logger(__name__)
29
 
30
 
 
185
  )
186
  )
187
 
188
+ # Store original function without validate_call to handle our own conversion
 
189
 
190
  return cls(
191
  name=func_name,
 
196
  fn=fn,
197
  )
198
 
199
+ def _convert_string_arguments(self, kwargs: dict[str, Any]) -> dict[str, Any]:
200
+ """Convert string arguments to expected types based on function signature."""
201
+ from fastmcp.server.context import Context
202
+
203
+ sig = inspect.signature(self.fn)
204
+ converted_kwargs = {}
205
+
206
+ # Find context parameter name if any
207
+ context_param_name = find_kwarg_by_type(self.fn, kwarg_type=Context)
208
+
209
+ for param_name, param_value in kwargs.items():
210
+ if param_name in sig.parameters:
211
+ param = sig.parameters[param_name]
212
+
213
+ # Skip Context parameters - they're handled separately
214
+ if param_name == context_param_name:
215
+ converted_kwargs[param_name] = param_value
216
+ continue
217
+
218
+ # If parameter has no annotation or annotation is str, pass as-is
219
+ if (
220
+ param.annotation == inspect.Parameter.empty
221
+ or param.annotation is str
222
+ ):
223
+ converted_kwargs[param_name] = param_value
224
+ # If argument is not a string, pass as-is (already properly typed)
225
+ elif not isinstance(param_value, str):
226
+ converted_kwargs[param_name] = param_value
227
+ else:
228
+ # Try to convert string argument using type adapter
229
+ try:
230
+ adapter = get_cached_typeadapter(param.annotation)
231
+ # Try JSON parsing first for complex types
232
+ try:
233
+ converted_kwargs[param_name] = adapter.validate_json(
234
+ param_value
235
+ )
236
+ except (ValueError, TypeError, pydantic_core.ValidationError):
237
+ # Fallback to direct validation
238
+ converted_kwargs[param_name] = adapter.validate_python(
239
+ param_value
240
+ )
241
+ except (ValueError, TypeError, pydantic_core.ValidationError) as e:
242
+ # If conversion fails, provide informative error
243
+ raise ValueError(
244
+ f"Could not convert argument '{param_name}' with value '{param_value}' "
245
+ f"to expected type {param.annotation}. Error: {e}"
246
+ )
247
+ else:
248
+ # Parameter not in function signature, pass as-is
249
+ converted_kwargs[param_name] = param_value
250
+
251
+ return converted_kwargs
252
+
253
  async def render(
254
  self,
255
  arguments: dict[str, Any] | None = None,
 
272
  if context_kwarg and context_kwarg not in kwargs:
273
  kwargs[context_kwarg] = get_context()
274
 
275
+ # Convert string arguments to expected types when needed
276
+ kwargs = self._convert_string_arguments(kwargs)
277
+
278
  # Call function and check if result is a coroutine
279
  result = self.fn(**kwargs)
280
  if inspect.iscoroutine(result):
tests/prompts/test_prompt.py CHANGED
@@ -240,3 +240,127 @@ class TestRenderPrompt:
240
  ),
241
  )
242
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  ),
241
  )
242
  ]
243
+
244
+
245
+ class TestPromptTypeConversion:
246
+ async def test_list_of_integers_as_string_args(self):
247
+ """Test that prompts can handle complex types passed as strings from MCP spec."""
248
+
249
+ def sum_numbers(numbers: list[int]) -> str:
250
+ """Calculate the sum of a list of numbers."""
251
+ total = sum(numbers)
252
+ return f"The sum is: {total}"
253
+
254
+ prompt = Prompt.from_function(sum_numbers)
255
+
256
+ # MCP spec only allows string arguments, so this should work
257
+ # after we implement type conversion
258
+ result_from_string = await prompt.render(
259
+ arguments={"numbers": "[1, 2, 3, 4, 5]"}
260
+ )
261
+ assert result_from_string == [
262
+ PromptMessage(
263
+ role="user", content=TextContent(type="text", text="The sum is: 15")
264
+ )
265
+ ]
266
+
267
+ # Both should work now with string conversion
268
+ result_from_list_string = await prompt.render(
269
+ arguments={"numbers": "[1, 2, 3, 4, 5]"}
270
+ )
271
+ assert result_from_list_string == result_from_string
272
+
273
+ async def test_various_type_conversions(self):
274
+ """Test type conversion for various data types."""
275
+
276
+ def process_data(
277
+ name: str,
278
+ age: int,
279
+ scores: list[float],
280
+ metadata: dict[str, str],
281
+ active: bool,
282
+ ) -> str:
283
+ return f"{name} ({age}): {len(scores)} scores, active={active}, metadata keys={list(metadata.keys())}"
284
+
285
+ prompt = Prompt.from_function(process_data)
286
+
287
+ # All arguments as strings (as MCP would send them)
288
+ result = await prompt.render(
289
+ arguments={
290
+ "name": "Alice",
291
+ "age": "25",
292
+ "scores": "[1.5, 2.0, 3.5]",
293
+ "metadata": '{"project": "test", "version": "1.0"}',
294
+ "active": "true",
295
+ }
296
+ )
297
+
298
+ expected_text = (
299
+ "Alice (25): 3 scores, active=True, metadata keys=['project', 'version']"
300
+ )
301
+ assert result == [
302
+ PromptMessage(
303
+ role="user", content=TextContent(type="text", text=expected_text)
304
+ )
305
+ ]
306
+
307
+ async def test_type_conversion_error_handling(self):
308
+ """Test that informative errors are raised for invalid type conversions."""
309
+ from fastmcp.exceptions import PromptError
310
+
311
+ def typed_prompt(numbers: list[int]) -> str:
312
+ return f"Got {len(numbers)} numbers"
313
+
314
+ prompt = Prompt.from_function(typed_prompt)
315
+
316
+ # Test with invalid JSON - should raise PromptError due to exception handling in render()
317
+ with pytest.raises(PromptError) as exc_info:
318
+ await prompt.render(arguments={"numbers": "not valid json"})
319
+
320
+ assert f"Error rendering prompt {prompt.name}" in str(exc_info.value)
321
+
322
+ async def test_json_parsing_fallback(self):
323
+ """Test that JSON parsing falls back to direct validation when needed."""
324
+
325
+ def data_prompt(value: int) -> str:
326
+ return f"Value: {value}"
327
+
328
+ prompt = Prompt.from_function(data_prompt)
329
+
330
+ # This should work with JSON parsing (integer as string)
331
+ result1 = await prompt.render(arguments={"value": "42"})
332
+ assert result1 == [
333
+ PromptMessage(
334
+ role="user", content=TextContent(type="text", text="Value: 42")
335
+ )
336
+ ]
337
+
338
+ # This should work with direct validation (already an integer string)
339
+ result2 = await prompt.render(arguments={"value": "123"})
340
+ assert result2 == [
341
+ PromptMessage(
342
+ role="user", content=TextContent(type="text", text="Value: 123")
343
+ )
344
+ ]
345
+
346
+ async def test_mixed_string_and_typed_args(self):
347
+ """Test mixing string args (no conversion) with typed args (conversion needed)."""
348
+
349
+ def mixed_prompt(message: str, count: int) -> str:
350
+ return f"{message} (repeated {count} times)"
351
+
352
+ prompt = Prompt.from_function(mixed_prompt)
353
+
354
+ result = await prompt.render(
355
+ arguments={
356
+ "message": "Hello world", # str - no conversion needed
357
+ "count": "3", # int - conversion needed
358
+ }
359
+ )
360
+
361
+ assert result == [
362
+ PromptMessage(
363
+ role="user",
364
+ content=TextContent(type="text", text="Hello world (repeated 3 times)"),
365
+ )
366
+ ]