modelcontextprotocol / modelcontextprotocol/python-sdk
A tool returning an empty list produces a CallToolResult with zero content blocks
Chưa có ai nhận issue này.
- Ngôn ngữ chính
- Python
- Star
- 24.3k
- Fork
- 4k
- Merge trung bình
- 1 ngày 1 giờ
- Pull request đã merge (30 ngày)
- 31
Mô tả
Initial Checks
- I confirm that I'm using the newest release of my line (mcp 2.0.0)
- I confirm that I searched for my issue before opening this issue
Release line
2.x
Description
A tool that returns an empty list produces a CallToolResult with zero content blocks. For a client that reads unstructured content, "no matches" and "the call returned nothing" become the same thing.
_convert_to_content (src/mcp/server/mcpserver/utilities/func_metadata.py:563) flattens a list by concatenating the conversion of each item:
if isinstance(result, list | tuple):
return list(chain.from_iterable(_convert_to_content(item) for item in result))
With zero items there is nothing to concatenate, so the result is []. That is a natural consequence of the flattening rule rather than an oversight, but it produces an asymmetry that is surprising in practice — the same "empty" answer behaves differently depending on the return type:
| tool returns | content blocks | text a client sees |
|---|---|---|
[] |
0 | '' |
[{...}, {...}] |
2 | both items |
"" |
1 | '' |
structuredContent is populated correctly in all three cases ({'result': []} for the empty list), so a client that reads structured output is unaffected. The gap is specific to clients consuming unstructured content — which is the population _convert_to_content's own docstring says it exists to serve: "retained for purposes of backwards compatibility."
Why this matters in practice. We hit it with an LLM-facing directory search. A find_person tool that legitimately found nobody returned an empty content array, and from the model's side that is indistinguishable from a call that produced nothing — so it re-tried the same lookup with cosmetic variations instead of concluding the person did not exist. Observed in one turn: find_person("Martha") → nothing → find_person("Martha Highlander") → nothing → find_person("Martha") again. Three identical searches, no answer. We work around it today by patching _convert_to_content in our own server so an empty list serializes to one TextContent holding [].
Worth noting the SDK already states this rule for tool authors — the docs advise returning [] rather than "" for an empty result — but a tool that follows that advice is exactly the one whose content array comes back empty.
Possible fix. In the list branch, when the conversion yields no blocks, emit a single TextContent with the serialized empty collection rather than an empty list. That keeps every non-empty case byte-identical and changes only the currently-empty one. I have not opened a PR: it changes output for every list-returning tool on the unstructured path, so it seemed worth agreeing on the direction (and on whether the empty-content array is considered correct as-is) before writing code. Happy to put one up if you'd like it.
Example Code
"""A tool returning an empty list yields a CallToolResult with zero content blocks."""
import asyncio
from mcp import Client
from mcp.client._memory import InMemoryTransport
from mcp.server.mcpserver import MCPServer
server = MCPServer("repro")
@server.tool()
def find_person(name: str) -> list[dict]:
"""Return matching people; an empty list means no match."""
return []
@server.tool()
def find_two(name: str) -> list[dict]:
"""Two matches, for contrast."""
return [{"name": "a"}, {"name": "b"}]
@server.tool()
def find_person_str(name: str) -> str:
"""The same answer as a string, for comparison."""
return ""
async def main() -> None:
async with Client(InMemoryTransport(server)) as client:
for tool in ("find_person", "find_two", "find_person_str"):
result = await client.call_tool(tool, {"name": "Martha"})
text = "".join(b.text for b in result.content if b.type == "text")
print(f"{tool}:")
print(f" content blocks = {len(result.content)}")
print(f" text seen by model= {text!r}")
print(f" structuredContent = {result.structured_content!r}")
asyncio.run(main())
Output:
find_person:
content blocks = 0
text seen by model= ''
structuredContent = {'result': []}
find_two:
content blocks = 2
text seen by model= '{\n "name": "a"\n}{\n "name": "b"\n}'
structuredContent = {'result': [{'name': 'a'}, {'name': 'b'}]}
find_person_str:
content blocks = 1
text seen by model= ''
structuredContent = {'result': ''}
Python & MCP Python SDK
python 3.14.3
mcp 2.0.0
platform macOS-15.6.1-arm64-arm-64bit-Mach-O
AI assistance disclosure, per CONTRIBUTING: this issue was investigated and drafted with Claude Code. The repro above was run and its output pasted verbatim, and I reviewed the whole thing before filing.
Hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Hướng nghiên cứu
Bắt đầu với _convert_to_content tại src/mcp/server/mcpserver/utilities/func_metadata.py:563 và so sánh hành vi với danh sách rỗng của nó với đường dẫn nội dung không có cấu trúc được ghi lại. Sử dụng các trường hợp tái hiện find_person và find_two của issue để xác minh rằng các kết quả không rỗng vẫn không thay đổi và hành vi đã thống nhất đối với danh sách rỗng được bao phủ.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Đánh giá
- Công nghệ
- python
- Lĩnh vực
- api
- Loại issue
- Lỗi
- Độ khó
- 2/5
- Thời gian dự kiến
- 1-3 giờ
- Mức độ hoạt động
- Ít trao đổi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức phù hợp với người mới
- 55/100