mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +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=(",", ":"))
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue