Skip to content

Commit 7df2b3b

Browse files
committed
Add methods to query health endpoints
1 parent aa5a7a3 commit 7df2b3b

File tree

3 files changed

+193
-0
lines changed

3 files changed

+193
-0
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ client = McpdClient(api_endpoint="http://localhost:8090", api_key="optional-key"
134134

135135
* `client.call.<server_name>.<tool_name>(**kwargs)` - The primary way to dynamically call any tool using keyword arguments.
136136

137+
* `client.server_health() -> dict[str, dict]` - Returns a dictionary mapping each server name to the health information of that server.
138+
139+
* `client.server_health(server_name: str) -> dict` - Returns the health information for only the specified server.
140+
141+
* `client.is_server_healthy(server_name: str) -> bool` - Checks if the specified server is healthy and can handle requests.
142+
137143
## Error Handling
138144

139145
All SDK-level errors, including HTTP and connection errors, will raise a `McpdError` exception.

src/mcpd/mcpd_client.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,128 @@ def clear_agent_tools_cache(self) -> None:
430430
>>> tools_v2 = client.agent_tools() # Regenerates from latest definitions
431431
"""
432432
self._function_builder.clear_cache()
433+
434+
def _get_server_health(self, server_name: str | None = None) -> list[dict] | dict:
435+
"""Get health information for one or all MCP servers."""
436+
try:
437+
if server_name:
438+
url = f"{self._endpoint}/api/v1/health/servers/{server_name}"
439+
else:
440+
url = f"{self._endpoint}/api/v1/health/servers"
441+
response = self._session.get(url, timeout=5)
442+
response.raise_for_status()
443+
data = response.json()
444+
return data if server_name else data.get("servers", [])
445+
except requests.exceptions.ConnectionError as e:
446+
raise ConnectionError(f"Cannot connect to mcpd daemon at {self._endpoint}: {e}") from e
447+
except requests.exceptions.Timeout as e:
448+
operation = f"get health of {server_name}" if server_name else "get health of all servers"
449+
raise TimeoutError("Request timed out after 5 seconds", operation=operation, timeout=5) from e
450+
except requests.exceptions.HTTPError as e:
451+
if e.response.status_code == 401:
452+
msg = (
453+
f"Authentication failed when accessing server '{server_name}': {e}"
454+
if server_name
455+
else f"Authentication failed: {e}"
456+
)
457+
raise AuthenticationError(msg) from e
458+
elif e.response.status_code == 404:
459+
assert server_name is not None
460+
raise ServerNotFoundError(f"Server '{server_name}' not found", server_name=server_name) from e
461+
else:
462+
msg = (
463+
f"Error retrieving health status for server '{server_name}': {e}"
464+
if server_name
465+
else f"Error retrieving health status for all servers: {e}"
466+
)
467+
raise McpdError(msg) from e
468+
except requests.exceptions.RequestException as e:
469+
msg = (
470+
f"Error retrieving health status for server '{server_name}': {e}"
471+
if server_name
472+
else f"Error retrieving health status for all servers: {e}"
473+
)
474+
raise McpdError(msg) from e
475+
476+
def server_health(self, server_name: str | None = None) -> dict[str, dict] | dict:
477+
"""Retrieve health information from one or all MCP servers.
478+
479+
Args:
480+
server_name: Optional name of a specific server to query. If None,
481+
retrieves health information from all available servers.
482+
483+
Returns:
484+
- If server_name is provided: The health information for that server.
485+
- If server_name is None: A dictionary mapping server names to their health information.
486+
487+
Server health is a dictionary that contains:
488+
- '$schema': A URL to the JSON schema
489+
- 'name': The server identifier
490+
- 'status': The current health status of the server ('ok', 'timeout', 'unreachable', 'unknown')
491+
- 'latency': The latency of the server in milliseconds (optional)
492+
- 'lastChecked': Time when ping was last attempted (optional)
493+
- 'lastSuccessful': Time of the most recent successful ping (optional)
494+
495+
Raises:
496+
ConnectionError: If unable to connect to the mcpd daemon.
497+
TimeoutError: If requests to the daemon time out.
498+
AuthenticationError: If API key authentication fails.
499+
ServerNotFoundError: If the specified server doesn't exist (when server_name provided).
500+
McpdError: For other daemon errors or API issues.
501+
502+
Examples:
503+
>>> client = McpdClient(api_endpoint="http://localhost:8090")
504+
>>>
505+
>>> # Get health for a specific server
506+
>>> health_info = client.server_health(server_name="time")
507+
>>> print(health_info["status"])
508+
'ok'
509+
>>>
510+
>>> # Get health for all servers
511+
>>> all_health = client.server_health()
512+
>>> for server, health in all_health.items():
513+
... print(f"{server}: {health['status']}")
514+
fetch: ok
515+
time: ok
516+
"""
517+
if server_name:
518+
return self._get_server_health(server_name)
519+
520+
try:
521+
all_health = self._get_server_health()
522+
all_health_by_server = {}
523+
for health in all_health:
524+
all_health_by_server[health["name"]] = health
525+
return all_health_by_server
526+
except McpdError as e:
527+
raise McpdError(f"Could not retrieve all health information: {e}") from e
528+
529+
def is_server_healthy(self, server_name: str) -> bool:
530+
"""Check if the specified MCP server is healthy.
531+
532+
This method queries the server's health status and determines whether the server is healthy
533+
and therefore can handle requests or not. It's useful for validating an MCP server is ready
534+
before attempting to call one of its tools.
535+
536+
Args:
537+
server_name: The name of the MCP server to check.
538+
539+
Returns:
540+
True if the server is healthy, False otherwise.
541+
Returns False if the server doesn't exist, is unreachable, or if any
542+
other error occurs during the check.
543+
544+
Example:
545+
>>> client = McpdClient(api_endpoint="http://localhost:8090")
546+
>>>
547+
>>> # Check before calling
548+
>>> if client.is_server_healthy("time"):
549+
... result = client.call.time.get_current_time(timezone="UTC")
550+
... else:
551+
... print("The server is not ready to accept requests yet.")
552+
"""
553+
try:
554+
health = self.server_health(server_name=server_name)
555+
return health.get("status", None) == "ok"
556+
except McpdError:
557+
return False

tests/unit/test_mcpd_client.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,65 @@ def test_clear_agent_tools_cache(self, client):
151151
with patch.object(client._function_builder, "clear_cache") as mock_clear:
152152
client.clear_agent_tools_cache()
153153
mock_clear.assert_called_once()
154+
155+
@patch.object(Session, "get")
156+
def test_health_single_server(self, mock_get, client):
157+
mock_response = Mock()
158+
mock_response.json.return_value = {"name": "test_server", "status": "ok"}
159+
mock_response.raise_for_status.return_value = None
160+
mock_get.return_value = mock_response
161+
162+
result = client.server_health("test_server")
163+
164+
assert result == {"name": "test_server", "status": "ok"}
165+
mock_get.assert_called_once_with("http://localhost:8090/api/v1/health/servers/test_server", timeout=5)
166+
167+
@patch.object(Session, "get")
168+
def test_health_all_servers(self, mock_get, client):
169+
mock_response = Mock()
170+
mock_response.json.return_value = {
171+
"servers": [{"name": "server1", "status": "ok"}, {"name": "server2", "status": "unreachable"}]
172+
}
173+
mock_response.raise_for_status.return_value = None
174+
mock_get.return_value = mock_response
175+
176+
result = client.server_health()
177+
178+
assert result == {
179+
"server1": {"name": "server1", "status": "ok"},
180+
"server2": {"name": "server2", "status": "unreachable"},
181+
}
182+
mock_get.assert_called_once_with("http://localhost:8090/api/v1/health/servers", timeout=5)
183+
184+
@patch.object(Session, "get")
185+
def test_health_request_error(self, mock_get, client):
186+
mock_get.side_effect = RequestException("Connection failed")
187+
188+
with pytest.raises(McpdError, match="Error retrieving health status for all servers"):
189+
client.server_health()
190+
191+
@patch.object(McpdClient, "server_health")
192+
def test_is_healthy_true(self, mock_health, client):
193+
mock_health.return_value = {"name": "test_server", "status": "ok"}
194+
195+
result = client.is_server_healthy("test_server")
196+
197+
assert result is True
198+
mock_health.assert_called_once_with(server_name="test_server")
199+
200+
@patch.object(McpdClient, "server_health")
201+
def test_is_healthy_false(self, mock_health, client):
202+
for status in ["timeout", "unknown", "unreachable"]:
203+
mock_health.return_value = {"name": "test_server", "status": status}
204+
205+
result = client.is_server_healthy("test_server")
206+
207+
assert result is False
208+
209+
@patch.object(McpdClient, "server_health")
210+
def test_is_healthy_error(self, mock_health, client):
211+
mock_health.side_effect = McpdError("Health check failed")
212+
213+
result = client.is_server_healthy("test_server")
214+
215+
assert result is False

0 commit comments

Comments
 (0)