From 022547ad8c957753d0b5a09290a4be345d3f01d2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:56:05 -0400 Subject: [PATCH] Preserve string-compatible prompt arguments (#4730) --- .../fastmcp/prompts/function_prompt.py | 39 ++++++--- tests/prompts/test_prompt.py | 83 +++++++++++++++++-- 2 files changed, 104 insertions(+), 18 deletions(-) diff --git a/fastmcp_slim/fastmcp/prompts/function_prompt.py b/fastmcp_slim/fastmcp/prompts/function_prompt.py index 9685630b1..e17d17d4e 100644 --- a/fastmcp_slim/fastmcp/prompts/function_prompt.py +++ b/fastmcp_slim/fastmcp/prompts/function_prompt.py @@ -217,7 +217,10 @@ class FunctionPrompt(Prompt): schema_str = json.dumps(param_schema, separators=(",", ":")) # Append schema info to description - schema_note = f"Provide as a JSON string matching the following schema: {schema_str}" + schema_note = ( + "Provide a value matching the following JSON schema: " + f"{schema_str}. Encode non-string values as JSON." + ) if arg_description: arg_description = f"{arg_description}\n\n{schema_note}" else: @@ -263,26 +266,38 @@ class FunctionPrompt(Prompt): if param_name in sig.parameters: param = sig.parameters[param_name] - # If parameter has no annotation or annotation is str, pass as-is - if ( - param.annotation == inspect.Parameter.empty - or param.annotation is str - ) or not isinstance(param_value, str): + if param.annotation == inspect.Parameter.empty or not isinstance( + param_value, str + ): converted_kwargs[param_name] = param_value else: # Try to convert string argument using type adapter try: adapter = get_cached_typeadapter(param.annotation) - # Try JSON parsing first for complex types + # Preserve the MCP wire string when validation keeps it + # as a string. Non-string results still prefer JSON + # decoding so coercible types such as bytes and Path do + # not retain JSON quote characters. try: + python_value = adapter.validate_python(param_value) + except (ValueError, TypeError, pydantic_core.ValidationError): converted_kwargs[param_name] = adapter.validate_json( param_value ) - except (ValueError, TypeError, pydantic_core.ValidationError): - # Fallback to direct validation - converted_kwargs[param_name] = adapter.validate_python( - param_value - ) + else: + if isinstance(python_value, str): + converted_kwargs[param_name] = python_value + else: + try: + converted_kwargs[param_name] = ( + adapter.validate_json(param_value) + ) + except ( + ValueError, + TypeError, + pydantic_core.ValidationError, + ): + converted_kwargs[param_name] = python_value except (ValueError, TypeError, pydantic_core.ValidationError) as e: # If conversion fails, provide informative error raise PromptError( diff --git a/tests/prompts/test_prompt.py b/tests/prompts/test_prompt.py index 201632e01..d44668783 100644 --- a/tests/prompts/test_prompt.py +++ b/tests/prompts/test_prompt.py @@ -1,5 +1,9 @@ +from pathlib import Path +from typing import Annotated, Any + import pytest from mcp_types import EmbeddedResource, TextResourceContents +from pydantic import Field from fastmcp.prompts.base import ( Message, @@ -313,8 +317,75 @@ class TestPromptTypeConversion: assert result.messages == [Message("Hello world (repeated 3 times)")] + @pytest.mark.parametrize( + ("annotation", "value"), + [ + (Annotated[str, Field(description="Text")], '"hello"'), + (str | None, "null"), + (Any, "123"), + (object, "true"), + (int | str, "42"), + ], + ) + async def test_string_compatible_annotations_preserve_wire_strings( + self, annotation: Any, value: str + ): + def typed_prompt(value): + return f"{type(value).__name__}:{value!r}" + + typed_prompt.__annotations__ = {"value": annotation, "return": str} + prompt = Prompt.from_function(typed_prompt) + + result = await prompt.render(arguments={"value": value}) + + assert result.messages == [Message(f"str:{value!r}")] + + async def test_optional_non_string_still_decodes_json_null(self): + def optional_integer_prompt(value: int | None) -> str: + return f"{type(value).__name__}:{value!r}" + + prompt = Prompt.from_function(optional_integer_prompt) + + result = await prompt.render(arguments={"value": "null"}) + + assert result.messages == [Message("NoneType:None")] + + @pytest.mark.parametrize( + ("annotation", "value", "expected"), + [ + (bytes, '"hello"', b"hello"), + (Path, '"folder/file.txt"', Path("folder/file.txt")), + ], + ) + async def test_string_coercible_non_string_annotations_decode_json( + self, annotation: Any, value: str, expected: Any + ): + def typed_prompt(value): + return f"{type(value).__name__}:{value!r}" + + typed_prompt.__annotations__ = {"value": annotation, "return": str} + prompt = Prompt.from_function(typed_prompt) + + result = await prompt.render(arguments={"value": value}) + + assert result.messages == [Message(f"{type(expected).__name__}:{expected!r}")] + class TestPromptArgumentDescriptions: + def test_string_compatible_annotation_guidance_preserves_raw_strings(self): + def documented_prompt( + text: Annotated[str, Field(description="Text")], + ) -> str: + return text + + prompt = Prompt.from_function(documented_prompt) + + assert prompt.arguments is not None + text_arg = next(arg for arg in prompt.arguments if arg.name == "text") + assert text_arg.description is not None + assert "Provide as a JSON string" not in text_arg.description + assert "Encode non-string values as JSON." in text_arg.description + def test_enhanced_descriptions_for_non_string_types(self): """Test that non-string argument types get enhanced descriptions with JSON schema.""" @@ -343,7 +414,7 @@ class TestPromptArgumentDescriptions: assert numbers_arg is not None assert numbers_arg.description is not None assert ( - "Provide as a JSON string matching the following schema:" + "Provide a value matching the following JSON schema:" in numbers_arg.description ) assert '{"items":{"type":"integer"},"type":"array"}' in numbers_arg.description @@ -354,7 +425,7 @@ class TestPromptArgumentDescriptions: assert metadata_arg is not None assert metadata_arg.description is not None assert ( - "Provide as a JSON string matching the following schema:" + "Provide a value matching the following JSON schema:" in metadata_arg.description ) assert ( @@ -368,7 +439,7 @@ class TestPromptArgumentDescriptions: assert threshold_arg is not None assert threshold_arg.description is not None assert ( - "Provide as a JSON string matching the following schema:" + "Provide a value matching the following JSON schema:" in threshold_arg.description ) assert '{"type":"number"}' in threshold_arg.description @@ -379,7 +450,7 @@ class TestPromptArgumentDescriptions: assert active_arg is not None assert active_arg.description is not None assert ( - "Provide as a JSON string matching the following schema:" + "Provide a value matching the following JSON schema:" in active_arg.description ) assert '{"type":"boolean"}' in active_arg.description @@ -410,7 +481,7 @@ class TestPromptArgumentDescriptions: assert "A list of integers to process" in numbers_arg.description assert "\n\n" in numbers_arg.description # Should have newline separator assert ( - "Provide as a JSON string matching the following schema:" + "Provide a value matching the following JSON schema:" in numbers_arg.description ) @@ -427,7 +498,7 @@ class TestPromptArgumentDescriptions: # String parameters should not have schema enhancement if arg.description is not None: assert ( - "Provide as a JSON string matching the following schema:" + "Provide a value matching the following JSON schema:" not in arg.description )