Skip to content

Commit 483a8b5

Browse files
committed
fix(ping): use transport-level health check for SSE servers
Replace server.connected (static property) with tool_manager.check_server_health() which performs a real transport-level send_ping() across all transport types. Fixes #203
1 parent a47fcbe commit 483a8b5

2 files changed

Lines changed: 159 additions & 29 deletions

File tree

src/mcp_cli/commands/servers/ping.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,30 +121,43 @@ async def execute(self, **kwargs) -> CommandResult:
121121
output="No servers available to ping.",
122122
)
123123

124-
# Ping each server
124+
# Actually ping servers via transport-level health check.
125+
# This works for all transport types (stdio, SSE, HTTP).
126+
health = await tool_manager.check_server_health()
127+
125128
output.info("Pinging servers...")
126129
success = True
127130

128-
for server in servers:
131+
for idx, server in enumerate(servers):
129132
# Skip if filtering by targets and this server doesn't match
130133
if (
131134
targets
132135
and server.name not in targets
133-
and str(servers.index(server)) not in targets
136+
and str(idx) not in targets
134137
):
135138
continue
136139

137140
try:
138-
# Try to ping the server (check if it's connected)
139-
if server.connected:
140-
output.success(f"✓ {server.name}: Connected")
141+
# Use live health check result from transport.send_ping()
142+
server_health = health.get(server.name, {})
143+
ping_ok = server_health.get("ping_success", False)
144+
145+
if ping_ok:
146+
output.success(f"✓ {server.name}: Online")
141147
else:
142-
output.error(f"✗ {server.name}: Disconnected")
148+
status = server_health.get("status", "unreachable")
149+
output.error(f"✗ {server.name}: {status}")
143150
success = False
144151
except Exception as e:
145152
output.error(f"✗ {server.name}: Error - {str(e)}")
146153
success = False
147154

155+
online = sum(
156+
1 for s in servers
157+
if health.get(s.name, {}).get("ping_success", False)
158+
)
159+
output.info(f"{online}/{len(servers)} servers online")
160+
148161
return CommandResult(success=success)
149162

150163
except Exception as e:

tests/commands/definitions/test_ping_command.py

Lines changed: 139 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Tests for the ping command."""
22

33
import pytest
4-
from unittest.mock import Mock, patch
4+
from unittest.mock import Mock, AsyncMock, patch, PropertyMock
55
from mcp_cli.commands.servers.ping import PingCommand
66

77

@@ -33,7 +33,6 @@ def test_command_properties(self, command):
3333
async def test_execute_all_servers(self, command):
3434
"""Test pinging all servers."""
3535
from mcp_cli.tools.models import ServerInfo
36-
from unittest.mock import AsyncMock
3736

3837
mock_tm = Mock()
3938
mock_server = ServerInfo(
@@ -45,6 +44,9 @@ async def test_execute_all_servers(self, command):
4544
namespace="test",
4645
)
4746
mock_tm.get_server_info = AsyncMock(return_value=[mock_server])
47+
mock_tm.check_server_health = AsyncMock(return_value={
48+
"test-server": {"status": "healthy", "ping_success": True},
49+
})
4850

4951
with patch("chuk_term.ui.output"):
5052
result = await command.execute(tool_manager=mock_tm)
@@ -54,7 +56,6 @@ async def test_execute_all_servers(self, command):
5456
async def test_execute_specific_server(self, command):
5557
"""Test pinging a specific server."""
5658
from mcp_cli.tools.models import ServerInfo
57-
from unittest.mock import AsyncMock
5859

5960
mock_tm = Mock()
6061
mock_server = ServerInfo(
@@ -66,6 +67,9 @@ async def test_execute_specific_server(self, command):
6667
namespace="test",
6768
)
6869
mock_tm.get_server_info = AsyncMock(return_value=[mock_server])
70+
mock_tm.check_server_health = AsyncMock(return_value={
71+
"test-server": {"status": "healthy", "ping_success": True},
72+
})
6973

7074
with patch("chuk_term.ui.output"):
7175
result = await command.execute(tool_manager=mock_tm, server_index=0)
@@ -81,9 +85,8 @@ async def test_execute_no_tool_manager(self, command):
8185

8286
@pytest.mark.asyncio
8387
async def test_execute_failed_ping(self, command):
84-
"""Test when server is disconnected."""
88+
"""Test when server ping fails (transport-level)."""
8589
from mcp_cli.tools.models import ServerInfo
86-
from unittest.mock import AsyncMock
8790

8891
mock_tm = Mock()
8992
mock_server = ServerInfo(
@@ -95,6 +98,9 @@ async def test_execute_failed_ping(self, command):
9598
namespace="test",
9699
)
97100
mock_tm.get_server_info = AsyncMock(return_value=[mock_server])
101+
mock_tm.check_server_health = AsyncMock(return_value={
102+
"test-server": {"status": "unhealthy", "ping_success": False},
103+
})
98104

99105
with patch("chuk_term.ui.output"):
100106
result = await command.execute(tool_manager=mock_tm)
@@ -103,8 +109,6 @@ async def test_execute_failed_ping(self, command):
103109
@pytest.mark.asyncio
104110
async def test_execute_error_handling(self, command):
105111
"""Test error handling during ping."""
106-
from unittest.mock import AsyncMock
107-
108112
mock_tm = Mock()
109113
mock_tm.get_server_info = AsyncMock(side_effect=Exception("Network error"))
110114

@@ -130,7 +134,6 @@ async def test_execute_with_context_exception(self, command):
130134
async def test_execute_with_args_list(self, command):
131135
"""Test executing with args as list."""
132136
from mcp_cli.tools.models import ServerInfo
133-
from unittest.mock import AsyncMock
134137

135138
mock_tm = Mock()
136139
mock_server1 = ServerInfo(
@@ -150,6 +153,10 @@ async def test_execute_with_args_list(self, command):
150153
namespace="test",
151154
)
152155
mock_tm.get_server_info = AsyncMock(return_value=[mock_server1, mock_server2])
156+
mock_tm.check_server_health = AsyncMock(return_value={
157+
"server1": {"status": "healthy", "ping_success": True},
158+
"server2": {"status": "healthy", "ping_success": True},
159+
})
153160

154161
with patch("chuk_term.ui.output"):
155162
# Pass args as a list
@@ -162,7 +169,6 @@ async def test_execute_with_args_list(self, command):
162169
async def test_execute_with_args_string(self, command):
163170
"""Test executing with args as string."""
164171
from mcp_cli.tools.models import ServerInfo
165-
from unittest.mock import AsyncMock
166172

167173
mock_tm = Mock()
168174
mock_server = ServerInfo(
@@ -174,6 +180,9 @@ async def test_execute_with_args_string(self, command):
174180
namespace="test",
175181
)
176182
mock_tm.get_server_info = AsyncMock(return_value=[mock_server])
183+
mock_tm.check_server_health = AsyncMock(return_value={
184+
"server1": {"status": "healthy", "ping_success": True},
185+
})
177186

178187
with patch("chuk_term.ui.output"):
179188
# Pass args as a string
@@ -183,8 +192,6 @@ async def test_execute_with_args_string(self, command):
183192
@pytest.mark.asyncio
184193
async def test_execute_no_servers(self, command):
185194
"""Test when no servers are available."""
186-
from unittest.mock import AsyncMock
187-
188195
mock_tm = Mock()
189196
mock_tm.get_server_info = AsyncMock(return_value=[])
190197

@@ -197,7 +204,6 @@ async def test_execute_no_servers(self, command):
197204
async def test_execute_with_context_success(self, command):
198205
"""Test getting tool manager from context successfully."""
199206
from mcp_cli.tools.models import ServerInfo
200-
from unittest.mock import AsyncMock
201207

202208
mock_server = ServerInfo(
203209
id=1,
@@ -210,6 +216,9 @@ async def test_execute_with_context_success(self, command):
210216

211217
mock_tm = Mock()
212218
mock_tm.get_server_info = AsyncMock(return_value=[mock_server])
219+
mock_tm.check_server_health = AsyncMock(return_value={
220+
"test-server": {"status": "healthy", "ping_success": True},
221+
})
213222

214223
mock_ctx = Mock()
215224
mock_ctx.tool_manager = mock_tm
@@ -224,7 +233,6 @@ async def test_execute_with_context_success(self, command):
224233
async def test_execute_filter_by_index(self, command):
225234
"""Test filtering servers by index."""
226235
from mcp_cli.tools.models import ServerInfo
227-
from unittest.mock import AsyncMock
228236

229237
mock_tm = Mock()
230238
mock_server1 = ServerInfo(
@@ -244,6 +252,10 @@ async def test_execute_filter_by_index(self, command):
244252
namespace="test",
245253
)
246254
mock_tm.get_server_info = AsyncMock(return_value=[mock_server1, mock_server2])
255+
mock_tm.check_server_health = AsyncMock(return_value={
256+
"server1": {"status": "healthy", "ping_success": True},
257+
"server2": {"status": "healthy", "ping_success": True},
258+
})
247259

248260
with patch("chuk_term.ui.output"):
249261
# Filter by index "0" - should only match first server
@@ -263,21 +275,126 @@ async def test_execute_context_returns_none(self, command):
263275

264276
@pytest.mark.asyncio
265277
async def test_execute_server_ping_exception(self, command):
266-
"""Test when accessing server.connected raises an exception."""
267-
from unittest.mock import AsyncMock, PropertyMock
278+
"""Test when accessing health check raises an exception for a server."""
279+
from mcp_cli.tools.models import ServerInfo
268280

269281
mock_tm = Mock()
270-
271-
# Create a mock server that raises exception when connected is accessed
272-
mock_server = Mock()
273-
mock_server.name = "test-server"
274-
type(mock_server).connected = PropertyMock(
275-
side_effect=Exception("Connection check failed")
282+
mock_server = ServerInfo(
283+
id=1,
284+
name="test-server",
285+
status="running",
286+
connected=True,
287+
tool_count=5,
288+
namespace="test",
276289
)
290+
mock_tm.get_server_info = AsyncMock(return_value=[mock_server])
291+
# Return empty health dict so .get() works but returns no ping_success
292+
mock_tm.check_server_health = AsyncMock(return_value={})
293+
294+
with patch("chuk_term.ui.output"):
295+
result = await command.execute(tool_manager=mock_tm)
296+
# Should fail because the server didn't respond to ping
297+
assert result.success is False
277298

299+
@pytest.mark.asyncio
300+
async def test_execute_sse_server_ping(self, command):
301+
"""Test that ping works for SSE servers (issue #203).
302+
303+
SSE transports don't expose raw streams, so ping must use
304+
the transport-level health check instead.
305+
"""
306+
from mcp_cli.tools.models import ServerInfo, TransportType
307+
308+
mock_tm = Mock()
309+
mock_server = ServerInfo(
310+
id=0,
311+
name="sse-echo",
312+
status="running",
313+
connected=True,
314+
tool_count=3,
315+
namespace="sse-echo",
316+
transport=TransportType.SSE,
317+
url="http://localhost:8081/mcp",
318+
)
278319
mock_tm.get_server_info = AsyncMock(return_value=[mock_server])
320+
mock_tm.check_server_health = AsyncMock(return_value={
321+
"sse-echo": {"status": "healthy", "ping_success": True},
322+
})
279323

280324
with patch("chuk_term.ui.output"):
281325
result = await command.execute(tool_manager=mock_tm)
282-
# Should fail because the server ping raised an exception
326+
assert result.success is True
327+
# Verify check_server_health was called (not just server.connected)
328+
mock_tm.check_server_health.assert_awaited_once()
329+
330+
@pytest.mark.asyncio
331+
async def test_execute_mixed_transport_servers(self, command):
332+
"""Test ping with both stdio and SSE servers."""
333+
from mcp_cli.tools.models import ServerInfo, TransportType
334+
335+
mock_tm = Mock()
336+
stdio_server = ServerInfo(
337+
id=0,
338+
name="sqlite",
339+
status="running",
340+
connected=True,
341+
tool_count=5,
342+
namespace="sqlite",
343+
transport=TransportType.STDIO,
344+
)
345+
sse_server = ServerInfo(
346+
id=1,
347+
name="sse-echo",
348+
status="running",
349+
connected=True,
350+
tool_count=3,
351+
namespace="sse-echo",
352+
transport=TransportType.SSE,
353+
url="http://localhost:8081/mcp",
354+
)
355+
mock_tm.get_server_info = AsyncMock(
356+
return_value=[stdio_server, sse_server]
357+
)
358+
mock_tm.check_server_health = AsyncMock(return_value={
359+
"sqlite": {"status": "healthy", "ping_success": True},
360+
"sse-echo": {"status": "healthy", "ping_success": True},
361+
})
362+
363+
with patch("chuk_term.ui.output"):
364+
result = await command.execute(tool_manager=mock_tm)
365+
assert result.success is True
366+
367+
@pytest.mark.asyncio
368+
async def test_online_count_reported(self, command):
369+
"""Test that online/total count is reported."""
370+
from mcp_cli.tools.models import ServerInfo
371+
372+
mock_tm = Mock()
373+
servers = [
374+
ServerInfo(
375+
id=i,
376+
name=f"server{i}",
377+
status="running",
378+
connected=True,
379+
tool_count=1,
380+
namespace="test",
381+
)
382+
for i in range(3)
383+
]
384+
mock_tm.get_server_info = AsyncMock(return_value=servers)
385+
mock_tm.check_server_health = AsyncMock(return_value={
386+
"server0": {"status": "healthy", "ping_success": True},
387+
"server1": {"status": "unhealthy", "ping_success": False},
388+
"server2": {"status": "healthy", "ping_success": True},
389+
})
390+
391+
info_calls = []
392+
with patch("chuk_term.ui.output") as mock_output:
393+
mock_output.info = lambda msg: info_calls.append(msg)
394+
mock_output.success = lambda msg: None
395+
mock_output.error = lambda msg: None
396+
result = await command.execute(tool_manager=mock_tm)
397+
# One server failed, so overall success is False
283398
assert result.success is False
399+
# Check that the summary line was emitted
400+
assert any("2/3 servers online" in call for call in info_calls)

0 commit comments

Comments
 (0)