From 1849d32d755d98d458bf8df8c68614e0d09e2b44 Mon Sep 17 00:00:00 2001 From: agent-of-mkmeral Date: Mon, 6 Apr 2026 19:48:44 +0000 Subject: [PATCH 1/2] fix: forward meta to MCP task-augmented tool calls MCPClient's _create_call_tool_coroutine didn't forward the meta parameter to _call_tool_as_task_and_poll_async when using task-augmented execution. This meant that custom _meta per the MCP spec never reached the server for tools using the task execution path, even though it worked correctly for direct call_tool calls. Changes: - Pass meta from _create_call_tool_coroutine to _call_tool_as_task_and_poll_async - Add meta parameter to _call_tool_as_task_and_poll_async signature - Forward meta to session.experimental.call_tool_as_task() - Add tests for meta forwarding in sync, async, and None meta paths Follow-up from #1918 per @mkmeral's review comment. --- src/strands/tools/mcp/mcp_client.py | 5 +- .../tools/mcp/test_mcp_client_tasks.py | 74 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index 7574f4b65..dff44123f 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -592,7 +592,7 @@ def _create_call_tool_coroutine( async def _call_as_task() -> MCPCallToolResult: # When task-augmented execution is used, use the read_timeout_seconds parameter # (which is a timedelta) for the polling timeout. - return await self._call_tool_as_task_and_poll_async(name, arguments, poll_timeout=read_timeout_seconds) + return await self._call_tool_as_task_and_poll_async(name, arguments, poll_timeout=read_timeout_seconds, meta=meta) return _call_as_task() else: @@ -1100,6 +1100,7 @@ async def _call_tool_as_task_and_poll_async( arguments: dict[str, Any] | None = None, ttl: timedelta | None = None, poll_timeout: timedelta | None = None, + meta: dict[str, Any] | None = None, ) -> MCPCallToolResult: """Call a tool using task-augmented execution and poll until completion. @@ -1113,6 +1114,7 @@ async def _call_tool_as_task_and_poll_async( arguments: Optional arguments to pass to the tool. ttl: Task time-to-live. Uses configured value if not specified. poll_timeout: Timeout for polling. Uses configured value if not specified. + meta: Optional metadata to pass to the tool call per MCP spec (_meta). Returns: MCPCallToolResult: The final tool result after task completion. @@ -1133,6 +1135,7 @@ async def _call_tool_as_task_and_poll_async( name=name, arguments=arguments, ttl=ttl_ms, + meta=meta, ) task_id = create_result.task.taskId self._log_debug_with_thread("tool=<%s>, task_id=<%s> | task created", name, task_id) diff --git a/tests/strands/tools/mcp/test_mcp_client_tasks.py b/tests/strands/tools/mcp/test_mcp_client_tasks.py index 01d3b2763..c21db9e28 100644 --- a/tests/strands/tools/mcp/test_mcp_client_tasks.py +++ b/tests/strands/tools/mcp/test_mcp_client_tasks.py @@ -214,3 +214,77 @@ async def poll(task_id): result = await client.call_tool_async(tool_use_id="t", name="success_tool", arguments={}) assert result["status"] == "success" assert "Done" in result["content"][0].get("text", "") + + +class TestTaskMetaForwarding: + """Tests for meta parameter forwarding in task-augmented execution.""" + + def _setup_task_tool_with_meta(self, mock_session, tool_name: str) -> MagicMock: + """Helper to set up a mock task-enabled tool and return the experimental mock.""" + mock_session.get_server_capabilities = MagicMock(return_value=create_server_capabilities(True)) + mock_tool = MCPTool( + name=tool_name, + description="A test tool", + inputSchema={"type": "object"}, + execution=ToolExecution(taskSupport="optional"), + ) + mock_session.list_tools = AsyncMock(return_value=ListToolsResult(tools=[mock_tool], nextCursor=None)) + mock_create_result = MagicMock() + mock_create_result.task.taskId = "test-task-id" + mock_session.experimental = MagicMock() + mock_session.experimental.call_tool_as_task = AsyncMock(return_value=mock_create_result) + + async def successful_poll(task_id): + yield MagicMock(status="completed", statusMessage=None) + + mock_session.experimental.poll_task = successful_poll + mock_session.experimental.get_task_result = AsyncMock( + return_value=MCPCallToolResult(content=[MCPTextContent(type="text", text="Done")], isError=False) + ) + + return mock_session.experimental + + def test_call_tool_sync_forwards_meta_to_task(self, mock_transport, mock_session): + """Test that call_tool_sync forwards meta to call_tool_as_task.""" + experimental = self._setup_task_tool_with_meta(mock_session, "meta_tool") + meta = {"com.example/request_id": "abc-123"} + + with MCPClient(mock_transport["transport_callable"], tasks_config=TasksConfig()) as client: + client.list_tools_sync() + client.call_tool_sync( + tool_use_id="test-id", name="meta_tool", arguments={"param": "value"}, meta=meta + ) + + experimental.call_tool_as_task.assert_called_once() + call_kwargs = experimental.call_tool_as_task.call_args + assert call_kwargs.kwargs.get("meta") == meta + + @pytest.mark.asyncio + async def test_call_tool_async_forwards_meta_to_task(self, mock_transport, mock_session): + """Test that call_tool_async forwards meta to call_tool_as_task.""" + experimental = self._setup_task_tool_with_meta(mock_session, "meta_tool") + meta = {"com.example/trace_id": "xyz-456"} + + with MCPClient(mock_transport["transport_callable"], tasks_config=TasksConfig()) as client: + client.list_tools_sync() + await client.call_tool_async( + tool_use_id="test-id", name="meta_tool", arguments={"param": "value"}, meta=meta + ) + + experimental.call_tool_as_task.assert_called_once() + call_kwargs = experimental.call_tool_as_task.call_args + assert call_kwargs.kwargs.get("meta") == meta + + def test_call_tool_sync_forwards_none_meta_to_task(self, mock_transport, mock_session): + """Test that call_tool_sync forwards None meta to call_tool_as_task when not provided.""" + experimental = self._setup_task_tool_with_meta(mock_session, "no_meta_tool") + + with MCPClient(mock_transport["transport_callable"], tasks_config=TasksConfig()) as client: + client.list_tools_sync() + client.call_tool_sync( + tool_use_id="test-id", name="no_meta_tool", arguments={"param": "value"} + ) + + experimental.call_tool_as_task.assert_called_once() + call_kwargs = experimental.call_tool_as_task.call_args + assert call_kwargs.kwargs.get("meta") is None From 265d96006ba86e13b74c310faa61e00e8a51628f Mon Sep 17 00:00:00 2001 From: agent-of-mkmeral Date: Mon, 6 Apr 2026 20:54:01 +0000 Subject: [PATCH 2/2] fix: format line to pass lint (E501 line too long) --- src/strands/tools/mcp/mcp_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/strands/tools/mcp/mcp_client.py b/src/strands/tools/mcp/mcp_client.py index dff44123f..11ed9c75e 100644 --- a/src/strands/tools/mcp/mcp_client.py +++ b/src/strands/tools/mcp/mcp_client.py @@ -592,7 +592,9 @@ def _create_call_tool_coroutine( async def _call_as_task() -> MCPCallToolResult: # When task-augmented execution is used, use the read_timeout_seconds parameter # (which is a timedelta) for the polling timeout. - return await self._call_tool_as_task_and_poll_async(name, arguments, poll_timeout=read_timeout_seconds, meta=meta) + return await self._call_tool_as_task_and_poll_async( + name, arguments, poll_timeout=read_timeout_seconds, meta=meta + ) return _call_as_task() else: