Skip to content

Commit 8f3e1f4

Browse files
committed
comments
1 parent c550fbd commit 8f3e1f4

File tree

7 files changed

+128
-77
lines changed

7 files changed

+128
-77
lines changed

.codecov.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
coverage:
2-
ignore:
3-
- "src/strands/experimental/tools/tool_provider.py" # This is an interface, cannot meaningfully cover
2+
status:
3+
project:
4+
default:
5+
target: 90% # overall coverage threshold
6+
patch:
7+
default:
8+
target: 90% # patch coverage threshold
9+
base: auto
10+
# Only post patch coverage on decreases
11+
only_pulls: true

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,15 @@ dependencies = [
133133
"pytest-cov>=7.0.0,<8.0.0",
134134
"pytest-asyncio>=1.0.0,<1.3.0",
135135
"pytest-xdist>=3.0.0,<4.0.0",
136+
"pytest-timeout>=2.0.0,<3.0.0",
136137
"moto>=5.1.0,<6.0.0",
137138
]
138139

139140
[[tool.hatch.envs.hatch-test.matrix]]
140141
python = ["3.13", "3.12", "3.11", "3.10"]
141142

142143
[tool.hatch.envs.hatch-test.scripts]
143-
run = "pytest{env:HATCH_TEST_ARGS:} {args}" # Run with: hatch test
144+
run = "pytest{env:HATCH_TEST_ARGS:} --timeout=10 {args}" # Run with: hatch test
144145
run-cov = "pytest{env:HATCH_TEST_ARGS:} {args} --cov --cov-config=pyproject.toml --cov-report html --cov-report xml {args}" # Run with: hatch test -c
145146
cov-combine = []
146147
cov-report = []

src/strands/agent/agent.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,7 @@ def cleanup(self) -> None:
541541
Note: This method uses a "belt and braces" approach with automatic cleanup
542542
through __del__ as a fallback, but explicit cleanup is recommended.
543543
"""
544-
if self._cleanup_called:
544+
if getattr(self, "_cleanup_called", False):
545545
return
546546

547547
run_async(self.cleanup_async)
@@ -553,18 +553,22 @@ async def cleanup_async(self) -> None:
553553
such as MCP clients. It should be called when the agent is no longer needed
554554
to ensure proper resource cleanup.
555555
556-
Note: This method uses a "belt and braces" approach with automatic cleanup
557-
through __del__ as a fallback, but explicit cleanup is recommended.
556+
This method is idempotent and safe to call multiple times.
558557
"""
559-
if self._cleanup_called:
558+
# Use getattr with False default: if _cleanup_called was deleted during garbage collection,
559+
# we default to False (cleanup not called) to ensure cleanup still runs
560+
if getattr(self, "_cleanup_called", False):
560561
return
561562

562-
logger.debug("agent_id=<%s> | cleaning up agent resources", self.agent_id)
563+
agent_id = getattr(self, "agent_id", None)
564+
logger.debug("agent_id=<%s> | cleaning up agent resources", agent_id)
563565

564-
await self.tool_registry.cleanup_async()
566+
tool_registry = getattr(self, "tool_registry", None)
567+
if tool_registry:
568+
await tool_registry.cleanup_async()
565569

566570
self._cleanup_called = True
567-
logger.debug("agent_id=<%s> | agent cleanup complete", self.agent_id)
571+
logger.debug("agent_id=<%s> | agent cleanup complete", agent_id)
568572

569573
def __del__(self) -> None:
570574
"""Automatic cleanup when agent is garbage collected.
@@ -574,7 +578,8 @@ def __del__(self) -> None:
574578
try:
575579
self.cleanup()
576580
except Exception as e:
577-
logger.debug("agent_id=<%s>, error=<%s> | exception during __del__ cleanup", self.agent_id, e)
581+
agent_id = getattr(self, "agent_id", None)
582+
logger.debug("agent_id=<%s>, error=<%s> | exception during __del__ cleanup", agent_id, e)
578583

579584
async def stream_async(
580585
self, prompt: AgentInput = None, *, invocation_state: dict[str, Any] | None = None, **kwargs: Any

src/strands/tools/mcp/mcp_client.py

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -210,15 +210,13 @@ async def load_tools(self, **kwargs: Any) -> Sequence[AgentTool]:
210210

211211
while True:
212212
logger.debug("page=<%d>, token=<%s> | fetching tools page", page_count, pagination_token)
213-
paginated_tools = self.list_tools_sync(pagination_token)
213+
paginated_tools = self.list_tools_sync(pagination_token, prefix=self._prefix)
214214

215215
# Process each tool as we get it
216216
for tool in paginated_tools:
217217
# Apply filters
218218
if self._should_include_tool(tool):
219-
# Apply prefix if needed
220-
processed_tool = self._apply_prefix(tool)
221-
self._loaded_tools.append(processed_tool)
219+
self._loaded_tools.append(tool)
222220

223221
logger.debug(
224222
"page=<%d>, page_tools=<%d>, total_filtered=<%d> | processed page",
@@ -313,12 +311,18 @@ async def _set_close_event() -> None:
313311
self._tool_provider_started = False
314312
self._consumers = set()
315313

316-
def list_tools_sync(self, pagination_token: Optional[str] = None) -> PaginatedList[MCPAgentTool]:
314+
def list_tools_sync(
315+
self, pagination_token: Optional[str] = None, prefix: Optional[str] = None
316+
) -> PaginatedList[MCPAgentTool]:
317317
"""Synchronously retrieves the list of available tools from the MCP server.
318318
319319
This method calls the asynchronous list_tools method on the MCP session
320320
and adapts the returned tools to the AgentTool interface.
321321
322+
Args:
323+
pagination_token: Optional token for pagination
324+
prefix: Optional prefix to apply to tool names
325+
322326
Returns:
323327
List[AgentTool]: A list of available tools adapted to the AgentTool interface
324328
"""
@@ -332,7 +336,16 @@ async def _list_tools_async() -> ListToolsResult:
332336
list_tools_response: ListToolsResult = self._invoke_on_background_thread(_list_tools_async()).result()
333337
self._log_debug_with_thread("received %d tools from MCP server", len(list_tools_response.tools))
334338

335-
mcp_tools = [MCPAgentTool(tool, self) for tool in list_tools_response.tools]
339+
mcp_tools = []
340+
for tool in list_tools_response.tools:
341+
if prefix:
342+
prefixed_name = f"{prefix}_{tool.name}"
343+
mcp_tool = MCPAgentTool(tool, self, name_override=prefixed_name)
344+
logger.debug("tool_rename=<%s->%s> | renamed tool", tool.name, prefixed_name)
345+
else:
346+
mcp_tool = MCPAgentTool(tool, self)
347+
mcp_tools.append(mcp_tool)
348+
336349
self._log_debug_with_thread("successfully adapted %d MCP tools", len(mcp_tools))
337350
return PaginatedList[MCPAgentTool](mcp_tools, token=list_tools_response.nextCursor)
338351

@@ -670,31 +683,21 @@ def _should_include_tool(self, tool: MCPAgentTool) -> bool:
670683
if self._matches_patterns(tool, self._tool_filters["rejected"]):
671684
return False
672685

686+
print(f"Returning true for {tool.mcp_tool.name} {tool.tool_name}")
673687
return True
674688

675-
def _apply_prefix(self, tool: MCPAgentTool) -> MCPAgentTool:
676-
"""Apply prefix to a single tool if needed."""
677-
if not self._prefix:
678-
return tool
679-
680-
# Create new tool with prefixed agent name but preserve original MCP name
681-
old_name = tool.tool_name
682-
new_agent_name = f"{self._prefix}_{tool.mcp_tool.name}"
683-
new_tool = MCPAgentTool(tool.mcp_tool, tool.mcp_client, name_override=new_agent_name)
684-
logger.debug("tool_rename=<%s->%s> | renamed tool", old_name, new_agent_name)
685-
return new_tool
686-
687689
def _matches_patterns(self, tool: MCPAgentTool, patterns: list[_ToolFilterPattern]) -> bool:
688690
"""Check if tool matches any of the given patterns."""
689691
for pattern in patterns:
690692
if callable(pattern):
691693
if pattern(tool):
692694
return True
693695
elif isinstance(pattern, Pattern):
694-
if pattern.match(tool.tool_name):
696+
if pattern.match(tool.mcp_tool.name):
695697
return True
696698
elif isinstance(pattern, str):
697-
if pattern == tool.tool_name:
699+
print(f"checking {pattern} against {tool.mcp_tool.name}")
700+
if pattern == tool.mcp_tool.name:
698701
return True
699702
return False
700703

tests/strands/agent/test_agent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -894,7 +894,7 @@ def test_agent_cleanup():
894894
"""Test that agent cleanup method works correctly."""
895895
# Create a fresh agent to avoid fixture interference
896896
agent = Agent()
897-
897+
898898
with unittest.mock.patch("strands.agent.agent.run_async") as mock_run_async:
899899
agent.cleanup()
900900

tests/strands/tools/mcp/test_mcp_client_tool_provider.py

Lines changed: 78 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from unittest.mock import MagicMock, patch
55

66
import pytest
7+
from mcp.types import Tool as MCPTool
78

89
from strands.tools.mcp import MCPClient
910
from strands.tools.mcp.mcp_agent_tool import MCPAgentTool
@@ -41,12 +42,18 @@ def mock_agent_tool(mock_mcp_tool):
4142
return agent_tool
4243

4344

44-
def create_mock_tool(name: str) -> MagicMock:
45+
def create_mock_tool(tool_name: str, mcp_tool_name: str | None = None) -> MagicMock:
4546
"""Helper to create mock tools with specific names."""
4647
tool = MagicMock(spec=MCPAgentTool)
47-
tool.tool_name = name
48-
tool.mcp_tool = MagicMock()
49-
tool.mcp_tool.name = name
48+
tool.tool_name = tool_name
49+
tool.tool_spec = {
50+
"name": tool_name,
51+
"description": f"Description for {tool_name}",
52+
"inputSchema": {"json": {"type": "object", "properties": {}}},
53+
}
54+
tool.mcp_tool = MagicMock(spec=MCPTool)
55+
tool.mcp_tool.name = mcp_tool_name or tool_name
56+
tool.mcp_tool.description = f"Description for {tool_name}"
5057
return tool
5158

5259

@@ -146,8 +153,8 @@ async def test_load_tools_handles_pagination(mock_transport):
146153
# Should have called list_tools_sync twice
147154
assert mock_list_tools.call_count == 2
148155
# First call with no token, second call with "page2" token
149-
mock_list_tools.assert_any_call(None)
150-
mock_list_tools.assert_any_call("page2")
156+
mock_list_tools.assert_any_call(None, prefix=None)
157+
mock_list_tools.assert_any_call("page2", prefix=None)
151158

152159
assert len(tools) == 2
153160
assert tools[0] is tool1
@@ -236,31 +243,44 @@ async def test_rejected_filter_string_match(mock_transport):
236243
@pytest.mark.asyncio
237244
async def test_prefix_renames_tools(mock_transport):
238245
"""Test that prefix properly renames tools."""
239-
original_tool = create_mock_tool("original_name")
240-
original_tool.mcp_client = MagicMock()
246+
# Create a mock MCP tool (not MCPAgentTool)
247+
mock_mcp_tool = MagicMock()
248+
mock_mcp_tool.name = "original_name"
241249

242250
client = MCPClient(mock_transport, prefix="prefix")
243251
client._tool_provider_started = True
244252

253+
# Mock the session active state
254+
mock_thread = MagicMock()
255+
mock_thread.is_alive.return_value = True
256+
client._background_thread = mock_thread
257+
245258
with (
246-
patch.object(client, "list_tools_sync") as mock_list_tools,
259+
patch.object(client, "_invoke_on_background_thread") as mock_invoke,
247260
patch("strands.tools.mcp.mcp_client.MCPAgentTool") as mock_agent_tool_class,
248261
):
249-
mock_list_tools.return_value = PaginatedList([original_tool])
262+
# Mock the MCP server response
263+
mock_list_tools_result = MagicMock()
264+
mock_list_tools_result.tools = [mock_mcp_tool]
265+
mock_list_tools_result.nextCursor = None
250266

251-
new_tool = MagicMock(spec=MCPAgentTool)
252-
new_tool.tool_name = "prefix_original_name"
253-
mock_agent_tool_class.return_value = new_tool
267+
mock_future = MagicMock()
268+
mock_future.result.return_value = mock_list_tools_result
269+
mock_invoke.return_value = mock_future
254270

255-
tools = await client.load_tools()
271+
# Mock MCPAgentTool creation
272+
mock_agent_tool = MagicMock(spec=MCPAgentTool)
273+
mock_agent_tool.tool_name = "prefix_original_name"
274+
mock_agent_tool_class.return_value = mock_agent_tool
256275

257-
# Should create new MCPAgentTool with prefixed name
258-
mock_agent_tool_class.assert_called_once_with(
259-
original_tool.mcp_tool, original_tool.mcp_client, name_override="prefix_original_name"
260-
)
276+
# Call list_tools_sync directly to test prefix functionality
277+
result = client.list_tools_sync(prefix="prefix")
261278

262-
assert len(tools) == 1
263-
assert tools[0] is new_tool
279+
# Should create MCPAgentTool with prefixed name
280+
mock_agent_tool_class.assert_called_once_with(mock_mcp_tool, client, name_override="prefix_original_name")
281+
282+
assert len(result) == 1
283+
assert result[0] is mock_agent_tool
264284

265285

266286
@pytest.mark.asyncio
@@ -318,3 +338,41 @@ async def test_remove_consumer_cleanup_failure(mock_transport):
318338

319339
with pytest.raises(ToolProviderException, match="Failed to cleanup MCP client: Cleanup failed"):
320340
await client.remove_consumer("consumer1")
341+
342+
343+
def test_mcp_client_reuse_across_multiple_agents(mock_transport):
344+
"""Test that a single MCPClient can be used across multiple agents."""
345+
from strands import Agent
346+
347+
tool1 = create_mock_tool(tool_name="shared_echo", mcp_tool_name="echo")
348+
client = MCPClient(mock_transport, tool_filters={"allowed": ["echo"]}, prefix="shared")
349+
350+
with (
351+
patch.object(client, "list_tools_sync") as mock_list_tools,
352+
patch.object(client, "start") as mock_start,
353+
patch.object(client, "stop") as mock_stop,
354+
):
355+
mock_list_tools.return_value = PaginatedList([tool1])
356+
357+
# Create two agents with the same client
358+
agent_1 = Agent(tools=[client])
359+
agent_2 = Agent(tools=[client])
360+
361+
# Both agents should have the same tool
362+
assert "shared_echo" in agent_1.tool_names
363+
assert "shared_echo" in agent_2.tool_names
364+
assert agent_1.tool_names == agent_2.tool_names
365+
366+
# Client should only be started once
367+
mock_start.assert_called_once()
368+
369+
# First agent cleanup - client should remain active
370+
agent_1.cleanup()
371+
mock_stop.assert_not_called() # Should not stop yet
372+
373+
# Second agent should still work
374+
assert "shared_echo" in agent_2.tool_names
375+
376+
# Final cleanup when last agent is removed
377+
agent_2.cleanup()
378+
mock_stop.assert_called_once() # Now it should stop

tests_integ/mcp/test_mcp_tool_provider.py

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def short_names_only(tool) -> bool:
3535
agent = Agent(tools=[client])
3636
tool_names = agent.tool_names
3737

38-
assert "echo_with_delay" not in [name.replace("test_", "") for name in tool_names]
38+
assert "test_echo_with_delay" not in [name for name in tool_names]
3939
assert all(name.startswith("test_") for name in tool_names)
4040

4141
agent.cleanup()
@@ -91,29 +91,6 @@ def test_mcp_client_tool_provider_reuse():
9191
assert agent1.tool_names == agent2.tool_names
9292

9393
agent1.cleanup()
94-
agent2.cleanup()
95-
96-
97-
def test_mcp_client_reference_counting():
98-
"""Test that MCPClient uses reference counting - cleanup only happens when last consumer is removed."""
99-
filters: ToolFilters = {"allowed": ["echo"]}
100-
client = MCPClient(
101-
lambda: stdio_client(StdioServerParameters(command="python", args=["tests_integ/mcp/echo_server.py"])),
102-
tool_filters=filters,
103-
prefix="ref",
104-
)
105-
106-
# Create two agents with the same client
107-
agent1 = Agent(tools=[client])
108-
agent2 = Agent(tools=[client])
109-
110-
# Both should have the tool
111-
assert "ref_echo" in agent1.tool_names
112-
assert "ref_echo" in agent2.tool_names
113-
114-
# Agent 1 uses the tool
115-
result1 = agent1.tool.ref_echo(to_echo="Agent 1 Test")
116-
assert "Agent 1 Test" in str(result1)
11794

11895
# Agent 1 cleans up - client should still be active for agent 2
11996
agent1.cleanup()
@@ -122,7 +99,6 @@ def test_mcp_client_reference_counting():
12299
result2 = agent2.tool.ref_echo(to_echo="Agent 2 Test")
123100
assert "Agent 2 Test" in str(result2)
124101

125-
# Agent 2 cleans up - now client should be fully cleaned up
126102
agent2.cleanup()
127103

128104

0 commit comments

Comments
 (0)