-
Notifications
You must be signed in to change notification settings - Fork 705
Description
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.33anthropic: 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.