diff --git a/haproxy_mcp/tools/health.py b/haproxy_mcp/tools/health.py index 45c33c5..014d8e8 100644 --- a/haproxy_mcp/tools/health.py +++ b/haproxy_mcp/tools/health.py @@ -195,9 +195,14 @@ def register_health_tools(mcp): @mcp.tool() def haproxy_get_server_health( - backend: Annotated[str, Field(default="", description="Optional: Backend name to filter (e.g., 'pool_1'). Empty = all backends")] = "" + backend: Annotated[str, Field(default="", description="Optional: Backend name to filter (e.g., 'pool_1'). Empty = all backends")] = "", + show_all: Annotated[bool, Field(default=False, description="Show all slots including empty MAINT slots. Default: only active/configured servers")] = False, ) -> str: - """Get health status of all servers (low-level view). For domain-specific, use haproxy_domain_health.""" + """Get health status of all servers (low-level view). For domain-specific, use haproxy_domain_health. + + By default, hides empty pool slots (MAINT with no health check) to reduce output size. + Use show_all=True to see all slots including unconfigured ones. + """ if backend and not validate_backend_name(backend): return "Error: Invalid backend name (use alphanumeric, underscore, hyphen only)" try: @@ -207,6 +212,9 @@ def register_health_tools(mcp): if stat["svname"] not in ["FRONTEND", "BACKEND", ""]: if backend and stat["pxname"] != backend: continue + # Skip empty pool slots (MAINT with no health check = unconfigured) + if not show_all and stat["status"] == "MAINT" and not stat["check_status"]: + continue servers.append( f"• {stat['pxname']}/{stat['svname']}: {stat['status']} " f"(weight: {stat['weight']}, check: {stat['check_status']})" diff --git a/tests/unit/tools/test_health.py b/tests/unit/tools/test_health.py index 08e00cf..2054d49 100644 --- a/tests/unit/tools/test_health.py +++ b/tests/unit/tools/test_health.py @@ -406,6 +406,93 @@ class TestHaproxyGetServerHealth: assert "No servers found" in result + def test_get_server_health_hides_empty_maint_slots(self, mock_socket_class, mock_select, patch_config_paths, response_builder): + """Empty MAINT slots (no health check) are hidden by default.""" + mock_sock = mock_socket_class(responses={ + "show stat": response_builder.stat_csv([ + {"pxname": "pool_1", "svname": "pool_1_1", "status": "UP", "weight": 1, "check_status": "L4OK"}, + {"pxname": "pool_1", "svname": "pool_1_2", "status": "MAINT", "weight": 1, "check_status": ""}, + {"pxname": "pool_1", "svname": "pool_1_3", "status": "MAINT", "weight": 1, "check_status": ""}, + {"pxname": "pool_2", "svname": "pool_2_1", "status": "MAINT", "weight": 1, "check_status": ""}, + ]), + }) + + with patch("socket.socket", return_value=mock_sock): + from haproxy_mcp.tools.health import register_health_tools + mcp = MagicMock() + registered_tools = {} + + def capture_tool(): + def decorator(func): + registered_tools[func.__name__] = func + return func + return decorator + + mcp.tool = capture_tool + register_health_tools(mcp) + + result = registered_tools["haproxy_get_server_health"](backend="") + + assert "pool_1_1" in result + assert "pool_1_2" not in result + assert "pool_1_3" not in result + assert "pool_2" not in result + + def test_get_server_health_show_all_includes_empty_slots(self, mock_socket_class, mock_select, patch_config_paths, response_builder): + """show_all=True includes empty MAINT slots.""" + mock_sock = mock_socket_class(responses={ + "show stat": response_builder.stat_csv([ + {"pxname": "pool_1", "svname": "pool_1_1", "status": "UP", "weight": 1, "check_status": "L4OK"}, + {"pxname": "pool_1", "svname": "pool_1_2", "status": "MAINT", "weight": 1, "check_status": ""}, + ]), + }) + + with patch("socket.socket", return_value=mock_sock): + from haproxy_mcp.tools.health import register_health_tools + mcp = MagicMock() + registered_tools = {} + + def capture_tool(): + def decorator(func): + registered_tools[func.__name__] = func + return func + return decorator + + mcp.tool = capture_tool + register_health_tools(mcp) + + result = registered_tools["haproxy_get_server_health"](backend="", show_all=True) + + assert "pool_1_1" in result + assert "pool_1_2" in result + + def test_get_server_health_keeps_maint_with_check(self, mock_socket_class, mock_select, patch_config_paths, response_builder): + """MAINT servers with health check status are kept (intentionally maintained).""" + mock_sock = mock_socket_class(responses={ + "show stat": response_builder.stat_csv([ + {"pxname": "pool_1", "svname": "pool_1_1", "status": "MAINT", "weight": 1, "check_status": "L4OK"}, + ]), + }) + + with patch("socket.socket", return_value=mock_sock): + from haproxy_mcp.tools.health import register_health_tools + mcp = MagicMock() + registered_tools = {} + + def capture_tool(): + def decorator(func): + registered_tools[func.__name__] = func + return func + return decorator + + mcp.tool = capture_tool + register_health_tools(mcp) + + result = registered_tools["haproxy_get_server_health"](backend="") + + assert "pool_1_1" in result + assert "MAINT" in result + def test_get_server_health_haproxy_error(self, mock_socket_class, mock_select, patch_config_paths): """HAProxy error returns error message.""" def raise_error(*args, **kwargs):