diff --git a/src/claude_agent_sdk/__init__.py b/src/claude_agent_sdk/__init__.py index 91937a00..e8c082a8 100644 --- a/src/claude_agent_sdk/__init__.py +++ b/src/claude_agent_sdk/__init__.py @@ -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 diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index 57191000..90b7c52b 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -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( @@ -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": diff --git a/src/claude_agent_sdk/_internal/query.py b/src/claude_agent_sdk/_internal/query.py index 7aded938..b784594e 100644 --- a/src/claude_agent_sdk/_internal/query.py +++ b/src/claude_agent_sdk/_internal/query.py @@ -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", diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 5b792644..7c139e3a 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -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[ diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index e1397631..78946a48 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -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 = { diff --git a/tests/test_sdk_mcp_integration.py b/tests/test_sdk_mcp_integration.py index 1e456ad5..c9598f0e 100644 --- a/tests/test_sdk_mcp_integration.py +++ b/tests/test_sdk_mcp_integration.py @@ -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"]