Preserve string-compatible prompt arguments (#4730)

This commit is contained in:
Jeremiah Lowin 2026-08-02 14:56:05 -04:00 committed by GitHub
commit 022547ad8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 104 additions and 18 deletions

View file

@ -217,7 +217,10 @@ class FunctionPrompt(Prompt):
schema_str = json.dumps(param_schema, separators=(",", ":")) schema_str = json.dumps(param_schema, separators=(",", ":"))
# Append schema info to description # 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: if arg_description:
arg_description = f"{arg_description}\n\n{schema_note}" arg_description = f"{arg_description}\n\n{schema_note}"
else: else:
@ -263,26 +266,38 @@ class FunctionPrompt(Prompt):
if param_name in sig.parameters: if param_name in sig.parameters:
param = sig.parameters[param_name] param = sig.parameters[param_name]
# If parameter has no annotation or annotation is str, pass as-is if param.annotation == inspect.Parameter.empty or not isinstance(
if ( param_value, str
param.annotation == inspect.Parameter.empty ):
or param.annotation is str
) or not isinstance(param_value, str):
converted_kwargs[param_name] = param_value converted_kwargs[param_name] = param_value
else: else:
# Try to convert string argument using type adapter # Try to convert string argument using type adapter
try: try:
adapter = get_cached_typeadapter(param.annotation) 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: try:
python_value = adapter.validate_python(param_value)
except (ValueError, TypeError, pydantic_core.ValidationError):
converted_kwargs[param_name] = adapter.validate_json( converted_kwargs[param_name] = adapter.validate_json(
param_value param_value
) )
except (ValueError, TypeError, pydantic_core.ValidationError): else:
# Fallback to direct validation if isinstance(python_value, str):
converted_kwargs[param_name] = adapter.validate_python( converted_kwargs[param_name] = python_value
param_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: except (ValueError, TypeError, pydantic_core.ValidationError) as e:
# If conversion fails, provide informative error # If conversion fails, provide informative error
raise PromptError( raise PromptError(

View file

@ -1,5 +1,9 @@
from pathlib import Path
from typing import Annotated, Any
import pytest import pytest
from mcp_types import EmbeddedResource, TextResourceContents from mcp_types import EmbeddedResource, TextResourceContents
from pydantic import Field
from fastmcp.prompts.base import ( from fastmcp.prompts.base import (
Message, Message,
@ -313,8 +317,75 @@ class TestPromptTypeConversion:
assert result.messages == [Message("Hello world (repeated 3 times)")] 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: 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): def test_enhanced_descriptions_for_non_string_types(self):
"""Test that non-string argument types get enhanced descriptions with JSON schema.""" """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 is not None
assert numbers_arg.description is not None assert numbers_arg.description is not None
assert ( assert (
"Provide as a JSON string matching the following schema:" "Provide a value matching the following JSON schema:"
in numbers_arg.description in numbers_arg.description
) )
assert '{"items":{"type":"integer"},"type":"array"}' 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 is not None
assert metadata_arg.description is not None assert metadata_arg.description is not None
assert ( assert (
"Provide as a JSON string matching the following schema:" "Provide a value matching the following JSON schema:"
in metadata_arg.description in metadata_arg.description
) )
assert ( assert (
@ -368,7 +439,7 @@ class TestPromptArgumentDescriptions:
assert threshold_arg is not None assert threshold_arg is not None
assert threshold_arg.description is not None assert threshold_arg.description is not None
assert ( assert (
"Provide as a JSON string matching the following schema:" "Provide a value matching the following JSON schema:"
in threshold_arg.description in threshold_arg.description
) )
assert '{"type":"number"}' 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 is not None
assert active_arg.description is not None assert active_arg.description is not None
assert ( assert (
"Provide as a JSON string matching the following schema:" "Provide a value matching the following JSON schema:"
in active_arg.description in active_arg.description
) )
assert '{"type":"boolean"}' 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 "A list of integers to process" in numbers_arg.description
assert "\n\n" in numbers_arg.description # Should have newline separator assert "\n\n" in numbers_arg.description # Should have newline separator
assert ( assert (
"Provide as a JSON string matching the following schema:" "Provide a value matching the following JSON schema:"
in numbers_arg.description in numbers_arg.description
) )
@ -427,7 +498,7 @@ class TestPromptArgumentDescriptions:
# String parameters should not have schema enhancement # String parameters should not have schema enhancement
if arg.description is not None: if arg.description is not None:
assert ( assert (
"Provide as a JSON string matching the following schema:" "Provide a value matching the following JSON schema:"
not in arg.description not in arg.description
) )