Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -561,12 +561,15 @@ def _convert_to_content(result: Any) -> list[ContentBlock]:
return [result.to_audio_content()]

if isinstance(result, list | tuple):
return list(
items = list(
chain.from_iterable(
_convert_to_content(item)
for item in result # type: ignore
)
)
if not result:
return [TextContent(type="text", text=json.dumps(list(result)))]
return items

if not isinstance(result, str):
result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
Expand Down
63 changes: 63 additions & 0 deletions tests/server/mcpserver/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -1308,3 +1308,66 @@ def fn() -> StepA | StepB: ... # pragma: no branch

meta = func_metadata(fn)
assert meta.output_schema is None


def test_empty_list_produces_one_text_content_block():
"""An empty-list return must not yield zero content blocks (issue #3305).

A client consuming unstructured content cannot distinguish 'no results'
from 'the call produced nothing' when content is an empty array, so the
serialized empty collection is emitted as a single TextContent block.
"""

def find_person(name: str) -> list[dict]: # pragma: no cover
return []

meta = func_metadata(find_person)
result = meta.convert_result([])

assert len(result.content) == 1
assert result.content[0].type == "text"
assert result.content[0].text == "[]"


def test_empty_tuple_produces_one_text_content_block():
"""Same guarantee for tuple return types."""

def fn() -> tuple[str, ...]: # pragma: no cover
return ()

meta = func_metadata(fn)
result = meta.convert_result(())

assert len(result.content) == 1
assert result.content[0].type == "text"
assert result.content[0].text == "[]"


def test_non_empty_list_content_blocks_unchanged():
"""Non-empty list behaviour must be byte-identical to before the fix."""

def find_people(name: str) -> list[dict]: # pragma: no cover
return []

meta = func_metadata(find_people)
result = meta.convert_result([{"name": "Alice"}, {"name": "Bob"}])

assert len(result.content) == 2
assert result.content[0].type == "text"
assert result.content[1].type == "text"


def test_empty_list_structured_content_unaffected():
"""structuredContent is populated correctly for empty lists regardless of the fix."""
from pydantic import BaseModel

class Person(BaseModel):
name: str

def find_person(name: str) -> list[Person]: # pragma: no cover
return []

meta = func_metadata(find_person)
result = meta.convert_result([])

assert result.structured_content == {"result": []}
Loading