modelcontextprotocol / modelcontextprotocol/python-sdk

A tool returning an empty list produces a CallToolResult with zero content blocks

Aperta
#3,305 1 commento 0 reazioni 0 assegnatari Vedi su GitHub

Nessuno ha ancora preso questa issue.

enhancement needs decision P2 v1 v2
Lingua principale
Python
Stelle
24.3k
Fork
4k
Merge medio
1g 1h
PR unite (30g)
31

Descrizione

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.

Guida per i contributori

Apri la guida per i contributori

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Direzione di ricerca

Inizia con _convert_to_content in src/mcp/server/mcpserver/utilities/func_metadata.py:563 e confronta il suo comportamento con una lista vuota con il percorso documentato per i contenuti non strutturati. Usa le riproduzioni find_person e find_two dell’issue per verificare che i risultati non vuoti rimangano invariati e che il comportamento concordato per una lista vuota sia coperto.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Valutazione

Stack tecnologico
python
Ambito
api
Tipo di issue
Bug
Difficoltà
2/5
Tempo stimato
1-3 ore
Stato di attività
Tranquilla
Chiarezza
Specificata chiaramente
Idoneità per principianti
55/100

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.