mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Preserve string-compatible prompt arguments (#4730)
This commit is contained in:
parent
34bdd480c9
commit
022547ad8c
2 changed files with 104 additions and 18 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue