Skip to content

search_result content blocks silently dropped by MCP tool result handler #574

@angusdavis2

Description

@angusdavis2

Summary

Custom tools defined with @tool that return search_result content blocks have those blocks silently dropped before they reach Claude. This prevents using native citations with custom RAG tools in the Agent SDK.

Environment

  • claude-agent-sdk: 0.1.33
  • anthropic: 0.79.0
  • Python: 3.14.2
  • OS: macOS (Darwin 24.3.0)

Expected behavior

A @tool handler returning {"content": [{"type": "search_result", ...}]} should pass the search_result block through to Claude, enabling Claude to produce structured citations (search_result_location) in its response — the same behavior as when using the anthropic SDK directly.

Actual behavior

The search_result block is silently dropped. Claude receives an empty tool result ({"content": []}) and cannot cite the content because it never sees it.

Root cause

The SDK's internal MCP tool result handler in claude_agent_sdk/_internal/query.py (around line 486-497) only recognizes two content block types:

for item in result.root.content:
    if hasattr(item, "text"):
        content.append({"type": "text", "text": item.text})
    elif hasattr(item, "data") and hasattr(item, "mimeType"):
        content.append({"type": "image", "data": item.data, "mimeType": item.mimeType})

A search_result block has neither a .text attribute nor .data+.mimeType attributes, so it falls through both checks and is silently discarded.

Additionally, message_parser.py (around line 98-123) only handles text, thinking, tool_use, and tool_result block types when parsing assistant messages. Even if search_result blocks reached Claude and it produced TextBlocks with citations fields, the parser would not preserve the citation data.

Reproduction

Minimal reproduction using ClaudeSDKClient with a custom tool:

import asyncio
import json
from claude_agent_sdk import (
    tool,
    create_sdk_mcp_server,
    ClaudeSDKClient,
    ClaudeAgentOptions,
    AssistantMessage,
    ResultMessage,
)


SEARCH_RESULT_BLOCK = {
    "type": "search_result",
    "source": "https://example.com/article",
    "title": "Example Article",
    "content": [
        {
            "type": "text",
            "text": "The answer to the question is 42.",
        }
    ],
    "citations": {"enabled": True},
}


@tool(
    name="search_kb",
    description="Search the knowledge base",
    input_schema={
        "type": "object",
        "properties": {"query": {"type": "string"}},
        "required": ["query"],
    },
)
async def search_kb(args):
    print(f"[tool called] search_kb({args})")
    return {"content": [SEARCH_RESULT_BLOCK]}


async def main():
    server = create_sdk_mcp_server(name="test", tools=[search_kb])

    options = ClaudeAgentOptions(
        model="claude-sonnet-4-5-20250929",
        max_turns=2,
        mcp_servers={"test": server},
        allowed_tools=["mcp__test__search_kb"],
        permission_mode="bypassPermissions",
        system_prompt="Use the search tool to answer questions.",
    )

    async with ClaudeSDKClient(options=options) as client:
        await client.query("What is the answer? Use the search tool.")

        async for msg in client.receive_messages():
            if hasattr(msg, "content") and isinstance(msg.content, list):
                for block in msg.content:
                    # Check tool result — content will be [] (empty)
                    if hasattr(block, "tool_use_id"):
                        print(f"Tool result content: {block.content}")
                        # Expected: [{"type": "search_result", ...}]
                        # Actual:   []

            if isinstance(msg, ResultMessage):
                break


asyncio.run(main())

Observed output — the tool result message shows:

{
  "content": [
    {
      "tool_use_id": "toolu_...",
      "content": [],
      "is_error": null
    }
  ]
}

The content array is empty — the search_result block was dropped.

For comparison, the same search_result block works correctly when sent via the anthropic SDK directly:

import anthropic, asyncio

async def main():
    client = anthropic.AsyncAnthropic()
    response = await client.messages.create(
        model="claude-sonnet-4-5-20250929",
        max_tokens=1024,
        messages=[
            {"role": "user", "content": "What is the answer?"},
            {"role": "assistant", "content": [
                {"type": "tool_use", "id": "tu_1", "name": "search", "input": {"q": "answer"}}
            ]},
            {"role": "user", "content": [
                {"type": "tool_result", "tool_use_id": "tu_1", "content": [
                    {
                        "type": "search_result",
                        "source": "https://example.com/article",
                        "title": "Example Article",
                        "content": [{"type": "text", "text": "The answer is 42."}],
                        "citations": {"enabled": True},
                    }
                ]}
            ]},
        ],
        tools=[{"name": "search", "description": "Search", "input_schema": {"type": "object", "properties": {"q": {"type": "string"}}}}],
    )
    for block in response.model_dump()["content"]:
        if block.get("citations"):
            print("Citations work:", block["citations"])

asyncio.run(main())

This produces structured citations with search_result_location type as expected.

Suggested fix

In query.py, the tool result content handler should pass through block types it doesn't recognize (including search_result) rather than silently dropping them:

for item in result.root.content:
    if hasattr(item, "text"):
        content.append({"type": "text", "text": item.text})
    elif hasattr(item, "data") and hasattr(item, "mimeType"):
        content.append({"type": "image", "data": item.data, "mimeType": item.mimeType})
    else:
        # Pass through unknown block types (e.g., search_result)
        # so they reach the Anthropic API intact
        if hasattr(item, "model_dump"):
            content.append(item.model_dump())
        elif isinstance(item, dict):
            content.append(item)

Similarly, message_parser.py should preserve the citations field on TextBlock responses from Claude, and ideally the ContentBlock union in types.py should include a type for citation-bearing text blocks.

Impact

This blocks using the Agent SDK for RAG applications that need structured, verifiable citations — a key feature of the Anthropic API's search results. The only workaround is to bypass the Agent SDK and use anthropic.AsyncAnthropic directly for any turn that needs citation support.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions