From 25a95321b0417244896e13aec051d76af088eddf Mon Sep 17 00:00:00 2001 From: Radhakrishnan Pachyappan Date: Mon, 17 Aug 2026 10:20:06 +0530 Subject: [PATCH] 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 --- .../mcpserver/utilities/func_metadata.py | 5 +- tests/server/mcpserver/test_func_metadata.py | 67 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index be4afb4e9b..889895aaa9 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -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(cast(list[Any], result)))] + return items if not isinstance(result, str): result = pydantic_core.to_json(result, fallback=str, indent=2).decode() diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 62a9612b95..38f5e84578 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -1308,3 +1308,70 @@ 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[str, Any]]: # pragma: no cover + return [] + + meta = func_metadata(find_person) + result = meta.convert_result([]) + + assert isinstance(result, CallToolResult) + 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 isinstance(result, CallToolResult) + 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[str, Any]]: # pragma: no cover + return [] + + meta = func_metadata(find_people) + result = meta.convert_result([{"name": "Alice"}, {"name": "Bob"}]) + + assert isinstance(result, CallToolResult) + 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 isinstance(result, CallToolResult) + assert result.structured_content == {"result": []}