Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/claude_agent_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,8 +516,11 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
item_type,
)

meta = result.get("_meta")
return CallToolResult(
content=content, isError=result.get("is_error", False)
content=content,
isError=result.get("is_error", False),
**({"_meta": meta} if meta is not None else {}),
)

# Return SDK server configuration
Expand Down
2 changes: 2 additions & 0 deletions src/claude_agent_sdk/_internal/message_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def parse_message(data: dict[str, Any]) -> Message | None:
tool_use_id=block["tool_use_id"],
content=block.get("content"),
is_error=block.get("is_error"),
meta=block.get("_meta"),
)
)
return UserMessage(
Expand Down Expand Up @@ -171,6 +172,7 @@ def parse_message(data: dict[str, Any]) -> Message | None:
tool_use_id=block["tool_use_id"],
content=block.get("content"),
is_error=block.get("is_error"),
meta=block.get("_meta"),
)
)
case "server_tool_use":
Expand Down
6 changes: 4 additions & 2 deletions src/claude_agent_sdk/_internal/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -690,9 +690,11 @@ async def _handle_sdk_mcp_request(
item_type,
)

response_data = {"content": content}
response_data: dict[str, Any] = {"content": content}
if hasattr(result.root, "isError") and result.root.isError:
response_data["isError"] = True # type: ignore[assignment]
response_data["isError"] = True
if getattr(result.root, "meta", None):
response_data["_meta"] = result.root.meta

return {
"jsonrpc": "2.0",
Expand Down
3 changes: 3 additions & 0 deletions src/claude_agent_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,9 @@ class ToolResultBlock:
tool_use_id: str
content: str | list[dict[str, Any]] | None = None
is_error: bool | None = None
meta: dict[str, Any] | None = None
"""MCP ``_meta`` payload from the tool result, for handler-only data that
should not enter model context (e.g. record IDs, pagination cursors)."""


ServerToolName = Literal[
Expand Down
40 changes: 40 additions & 0 deletions tests/test_message_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,46 @@ def test_parse_user_message_with_tool_result_error(self):
assert message.content[0].content == "File not found"
assert message.content[0].is_error is True

def test_parse_user_message_with_tool_result_meta(self):
"""Test that _meta on a tool_result block is exposed as handler-only data."""
data = {
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "tool_meta",
"content": "Recalled 3 observations",
"_meta": {"observation_ids": ["obs_1", "obs_2", "obs_3"]},
}
]
},
}
message = parse_message(data)
assert isinstance(message, UserMessage)
assert isinstance(message.content[0], ToolResultBlock)
assert message.content[0].meta == {
"observation_ids": ["obs_1", "obs_2", "obs_3"]
}

def test_parse_user_message_with_tool_result_no_meta(self):
"""Test that meta defaults to None when the block has no _meta key."""
data = {
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "tool_789",
"content": "File contents here",
}
]
},
}
message = parse_message(data)
assert isinstance(message.content[0], ToolResultBlock)
assert message.content[0].meta is None

def test_parse_user_message_with_mixed_content(self):
"""Test parsing a user message with mixed content blocks."""
data = {
Expand Down
77 changes: 77 additions & 0 deletions tests/test_sdk_mcp_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -1091,3 +1091,80 @@ async def cached(args: dict[str, Any]) -> dict[str, Any]:
response1 = await list_handler(request)
response2 = await list_handler(request)
assert response1.root.tools == response2.root.tools


@pytest.mark.anyio
async def test_tool_result_meta_reaches_call_tool_result():
"""A handler's ``_meta`` key is forwarded onto the resulting CallToolResult.

``_meta`` is the MCP-spec channel for handler-only data that must not enter
model context (record IDs, pagination cursors, etc.). Previously the SDK's
``call_tool`` handler only read ``content`` and ``is_error`` from the
handler's return value, silently dropping ``_meta``.
"""

@tool("recall", "Recalls observations", {})
async def recall(args: dict[str, Any]) -> dict[str, Any]:
return {
"content": [{"type": "text", "text": "Recalled 3 observations"}],
"_meta": {"observation_ids": ["obs_1", "obs_2", "obs_3"]},
}

server_config = create_sdk_mcp_server(name="meta-test", tools=[recall])
server = server_config["instance"]
call_handler = server.request_handlers[CallToolRequest]

request = CallToolRequest(
method="tools/call",
params=CallToolRequestParams(name="recall", arguments={}),
)
result = await call_handler(request)

assert result.root.meta == {"observation_ids": ["obs_1", "obs_2", "obs_3"]}


def test_tool_result_meta_flows_through_jsonrpc_bridge():
"""``_meta`` on a tool result survives the Query JSON-RPC bridge to the CLI.

Mirrors test_max_result_size_chars_annotation_flows_to_cli, but for the
tools/call response rather than tools/list.
"""
import anyio

from claude_agent_sdk._internal.query import Query

@tool("recall", "Recalls observations", {})
async def recall(args: dict[str, Any]) -> dict[str, Any]:
return {
"content": [{"type": "text", "text": "Recalled 3 observations"}],
"_meta": {"observation_ids": ["obs_1", "obs_2", "obs_3"]},
}

@tool("no_meta", "Returns a result with no meta", {})
async def no_meta(args: dict[str, Any]) -> dict[str, Any]:
return {"content": [{"type": "text", "text": "ok"}]}

server_config = create_sdk_mcp_server(
name="meta-bridge-test", tools=[recall, no_meta]
)

async def _run(tool_name: str, tool_id: int):
query_instance = Query.__new__(Query)
query_instance.sdk_mcp_servers = {"meta-bridge-test": server_config["instance"]}
return await query_instance._handle_sdk_mcp_request(
"meta-bridge-test",
{
"jsonrpc": "2.0",
"id": tool_id,
"method": "tools/call",
"params": {"name": tool_name, "arguments": {}},
},
)

with_meta = anyio.run(_run, "recall", 1)
assert with_meta["result"]["_meta"] == {
"observation_ids": ["obs_1", "obs_2", "obs_3"]
}

without_meta = anyio.run(_run, "no_meta", 2)
assert "_meta" not in without_meta["result"]