@@ -113,8 +113,11 @@ async def safe_call_tool(
113113) -> dict [str , Any ]:
114114 """Call an MCP tool and return parsed result, handling ToolError exceptions.
115115
116- This is useful for tests that expect tools to fail and want to inspect
117- the error response without catching exceptions manually.
116+ For a call whose outcome the test does NOT assert: ``finally``-block cleanup
117+ (so a cleanup failure cannot mask the real assertion) and service-availability
118+ probes. It swallows ``ToolError`` and returns a parsed dict either way, so a
119+ test that asserts a failure should use ``MCPAssertions.call_tool_failure()``
120+ with ``expected_error`` instead -- see tests/AGENTS.md "Test Patterns".
118121
119122 Args:
120123 mcp_client: The MCP client instance
@@ -131,17 +134,16 @@ async def safe_call_tool(
131134 return tool_error_to_result (exc )
132135
133136
134- def assert_mcp_success (result , operation_name : str = "operation" ):
135- """
136- Assert that MCP tool result indicates success.
137+ def looks_like_success (data : dict [str , Any ]) -> bool :
138+ """Whether a parsed tool result is a success response.
137139
138- Args:
139- result: FastMCP client result
140- operation_name: Name of operation for error message
140+ Shared by ``assert_mcp_success`` and ``assert_mcp_failure`` so the two
141+ cannot disagree about what "success" means. Several tools succeed
142+ WITHOUT a ``success`` key (``pending_restart``, bulk-operation
143+ payloads), so a bare ``data.get("success")`` check treats those as
144+ failures -- which is fine for the success assertion (it lists them
145+ explicitly) but silently accepted them as failures on the other side.
141146 """
142- data = parse_mcp_result (result )
143-
144- # Handle different success indicators
145147 success_indicators = [
146148 data .get ("success" ) is True ,
147149 # ha_manage_app's options/network write returns
@@ -172,7 +174,20 @@ def assert_mcp_success(result, operation_name: str = "operation"):
172174 ),
173175 ]
174176
175- if not any (success_indicators ):
177+ return any (success_indicators )
178+
179+
180+ def assert_mcp_success (result , operation_name : str = "operation" ):
181+ """
182+ Assert that MCP tool result indicates success.
183+
184+ Args:
185+ result: FastMCP client result
186+ operation_name: Name of operation for error message
187+ """
188+ data = parse_mcp_result (result )
189+
190+ if not looks_like_success (data ):
176191 error_msg = data .get ("error" , "Unknown error" )
177192 suggestions = data .get ("suggestions" , [])
178193
@@ -199,8 +214,12 @@ def assert_mcp_failure(
199214 """
200215 data = parse_mcp_result (result )
201216
202- # Check that operation actually failed
203- if data .get ("success" ):
217+ # Check that operation actually failed. Uses the shared success
218+ # predicate, not a bare data.get("success"): a tool that succeeds
219+ # without a success key (pending_restart, bulk-operation payloads)
220+ # would otherwise be accepted here as a failure, so a regression that
221+ # made an expected-failure call SUCCEED could pass unnoticed.
222+ if looks_like_success (data ):
204223 raise AssertionError (f"{ operation_name } should have failed but succeeded" )
205224
206225 # If expected error specified, check for it
@@ -380,8 +399,9 @@ async def call_tool_failure(
380399 except ToolError as exc :
381400 # Convert ToolError to result dict and validate
382401 data = tool_error_to_result (exc )
383- # Verify this is actually a failure
384- if data .get ("success" ):
402+ # Verify this is actually a failure (shared predicate, see
403+ # assert_mcp_failure)
404+ if looks_like_success (data ):
385405 raise AssertionError (
386406 f"{ operation_name } should have failed but succeeded"
387407 ) from exc
0 commit comments