Skip to content

Commit 25a9532

Browse files
committed
fix(mcpserver): emit one TextContent block when a tool returns an empty list
When a tool function returns an empty list or tuple, `func_metadata` was producing zero content blocks (an empty `CallToolResult.content`). The MCP spec requires at least one content item, so LLM clients that assume the list is non-empty would raise an index error or silently drop the result. Root cause: the branch that handled falsy sequences fell through to the normal `convert_result` path, which converts each element of the sequence into a `TextContent` block. An empty sequence produced nothing. Fix: detect `not result` before the element-wise conversion and return a single `TextContent` containing `[]` (the JSON serialisation of an empty list). Tuple return types are covered by the same branch. Signed-off-by: Radhakrishnan Pachyappan <gingeekrishna@gmail.com>
1 parent 37b3cb1 commit 25a9532

2 files changed

Lines changed: 71 additions & 1 deletion

File tree

src/mcp/server/mcpserver/utilities/func_metadata.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,12 +561,15 @@ def _convert_to_content(result: Any) -> list[ContentBlock]:
561561
return [result.to_audio_content()]
562562

563563
if isinstance(result, list | tuple):
564-
return list(
564+
items = list(
565565
chain.from_iterable(
566566
_convert_to_content(item)
567567
for item in result # type: ignore
568568
)
569569
)
570+
if not result:
571+
return [TextContent(type="text", text=json.dumps(cast(list[Any], result)))]
572+
return items
570573

571574
if not isinstance(result, str):
572575
result = pydantic_core.to_json(result, fallback=str, indent=2).decode()

tests/server/mcpserver/test_func_metadata.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,3 +1308,70 @@ def fn() -> StepA | StepB: ... # pragma: no branch
13081308

13091309
meta = func_metadata(fn)
13101310
assert meta.output_schema is None
1311+
1312+
1313+
def test_empty_list_produces_one_text_content_block():
1314+
"""An empty-list return must not yield zero content blocks (issue #3305).
1315+
1316+
A client consuming unstructured content cannot distinguish 'no results'
1317+
from 'the call produced nothing' when content is an empty array, so the
1318+
serialized empty collection is emitted as a single TextContent block.
1319+
"""
1320+
1321+
def find_person(name: str) -> list[dict[str, Any]]: # pragma: no cover
1322+
return []
1323+
1324+
meta = func_metadata(find_person)
1325+
result = meta.convert_result([])
1326+
1327+
assert isinstance(result, CallToolResult)
1328+
assert len(result.content) == 1
1329+
assert result.content[0].type == "text"
1330+
assert result.content[0].text == "[]"
1331+
1332+
1333+
def test_empty_tuple_produces_one_text_content_block():
1334+
"""Same guarantee for tuple return types."""
1335+
1336+
def fn() -> tuple[str, ...]: # pragma: no cover
1337+
return ()
1338+
1339+
meta = func_metadata(fn)
1340+
result = meta.convert_result(())
1341+
1342+
assert isinstance(result, CallToolResult)
1343+
assert len(result.content) == 1
1344+
assert result.content[0].type == "text"
1345+
assert result.content[0].text == "[]"
1346+
1347+
1348+
def test_non_empty_list_content_blocks_unchanged():
1349+
"""Non-empty list behaviour must be byte-identical to before the fix."""
1350+
1351+
def find_people(name: str) -> list[dict[str, Any]]: # pragma: no cover
1352+
return []
1353+
1354+
meta = func_metadata(find_people)
1355+
result = meta.convert_result([{"name": "Alice"}, {"name": "Bob"}])
1356+
1357+
assert isinstance(result, CallToolResult)
1358+
assert len(result.content) == 2
1359+
assert result.content[0].type == "text"
1360+
assert result.content[1].type == "text"
1361+
1362+
1363+
def test_empty_list_structured_content_unaffected():
1364+
"""structuredContent is populated correctly for empty lists regardless of the fix."""
1365+
from pydantic import BaseModel
1366+
1367+
class Person(BaseModel):
1368+
name: str
1369+
1370+
def find_person(name: str) -> list[Person]: # pragma: no cover
1371+
return []
1372+
1373+
meta = func_metadata(find_person)
1374+
result = meta.convert_result([])
1375+
1376+
assert isinstance(result, CallToolResult)
1377+
assert result.structured_content == {"result": []}

0 commit comments

Comments
 (0)