Skip to content

Commit b48657b

Browse files
Add methods to query health endpoints (#29)
* Add methods to query health endpoints * Add the cachetools library * Cache server health results * Fix test assertions for cacheable exceptions * Move assertions outside pytest.raises() context to ensure execution * Fix assertion to check exception type instead of ExceptionInfo instance * Add length check to ensure test covers all cacheable exceptions * Make patching consistent with rest of file using @patch.object decorator * Add mock reset between test iterations * Extract cache maxsize to private constant * Add _SERVER_HEALTH_CACHE_MAXSIZE constant for maintainability * Add proper docstrings for both class constants * Add test to verify constant value * Follows same pattern as _CACHEABLE_EXCEPTIONS * Add missing test and usage example for HealthStatus.is_transient() * Add comprehensive test coverage for is_transient() method * Test both transient (timeout, unknown) and non-transient (ok, unreachable) states * Also improve test coverage for is_healthy() method * Add usage example in server_health() docstring showing retry logic pattern * Improve docstrings for key private methods * Add complete docstring for _raise_for_server_health() with Args and Raises sections * Enhance _get_server_health() docs with reference to public API * Improve _get_tool_definitions() with comprehensive exception documentation * Helps developers working on public APIs understand private method contracts * Make is_server_healthy() error handling more specific * Only catch ServerUnhealthyError and ServerNotFoundError as 'not healthy' * Let connection/auth/timeout errors propagate to caller * Update docstring to document which exceptions can be raised * Update test to verify new error propagation behavior This improves error visibility - infrastructure issues (connection, auth) are now distinguishable from server health status issues. * Add thread safety to health check caching * Add threading.RLock to protect cache access * Pass lock to cached decorator for synchronized operations * Wrap cache.clear() and cache.pop() operations with lock * Document thread safety in class docstring The performance impact is negligible since network I/O dominates execution time. Cache stampede risk is minimal due to 10-second TTL and typically small number of MCP servers. * Add one more test case for test_is_healthy_error --------- Co-authored-by: Peter Wilson <peter@mozilla.ai>
1 parent c95cf31 commit b48657b

File tree

8 files changed

+734
-8
lines changed

8 files changed

+734
-8
lines changed

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ This SDK provides high-level and dynamic access to those tools, making it easy t
1212
- Retrieve tool definitions and schemas for one or all servers
1313
- Dynamically invoke any tool using a clean, attribute-based syntax
1414
- Generate self-contained, deepcopy-safe tool functions for frameworks like [any-agent](https://github.com/mozilla-ai/any-agent)
15-
- Minimal dependencies (`requests` only)
15+
- Minimal dependencies (`requests` and `cachetools` only)
1616

1717
## Installation in your project
1818

@@ -121,7 +121,8 @@ from mcpd import McpdClient
121121

122122
# Initialize the client with your mcpd API endpoint.
123123
# api_key is optional and sends an 'MCPD-API-KEY' header.
124-
client = McpdClient(api_endpoint="http://localhost:8090", api_key="optional-key")
124+
# server_health_cache_ttl is optional and sets the time in seconds to cache a server health response.
125+
client = McpdClient(api_endpoint="http://localhost:8090", api_key="optional-key", server_health_cache_ttl=10)
125126
```
126127

127128
### Core Methods
@@ -140,6 +141,12 @@ client = McpdClient(api_endpoint="http://localhost:8090", api_key="optional-key"
140141

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

144+
* `client.server_health() -> dict[str, dict]` - Returns a dictionary mapping each server name to the health information of that server.
145+
146+
* `client.server_health(server_name: str) -> dict` - Returns the health information for only the specified server.
147+
148+
* `client.is_server_healthy(server_name: str) -> bool` - Checks if the specified server is healthy and can handle requests.
149+
143150
## Error Handling
144151

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

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ readme = "README.md"
66
license = {text = "Apache-2.0"}
77
requires-python = ">=3.11"
88
dependencies = [
9+
"cachetools>=6.2.0",
910
"requests>=2.32.4",
1011
]
1112

src/mcpd/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,22 @@
1717
ConnectionError,
1818
McpdError,
1919
ServerNotFoundError,
20+
ServerUnhealthyError,
2021
TimeoutError,
2122
ToolExecutionError,
2223
ToolNotFoundError,
2324
ValidationError,
2425
)
25-
from .mcpd_client import McpdClient
26+
from .mcpd_client import HealthStatus, McpdClient
2627

2728
__all__ = [
2829
"McpdClient",
30+
"HealthStatus",
2931
"McpdError",
3032
"AuthenticationError",
3133
"ConnectionError",
3234
"ServerNotFoundError",
35+
"ServerUnhealthyError",
3336
"TimeoutError",
3437
"ToolExecutionError",
3538
"ToolNotFoundError",

src/mcpd/exceptions.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,41 @@ def __init__(self, message: str, server_name: str = None):
132132
self.server_name = server_name
133133

134134

135+
class ServerUnhealthyError(McpdError):
136+
"""Raised when a specified MCP server is not healthy.
137+
138+
This indicates that the server exists but is currently unhealthy:
139+
- The server is down or unreachable
140+
- Timeout occurred while checking health
141+
- No health data is available for the server
142+
143+
Attributes:
144+
server_name: The name of the server that is unhealthy.
145+
health_status: Details about the server's health status (if available).
146+
Can be one of timeout, unreachable, unknown.
147+
148+
Example:
149+
>>> try:
150+
>>> tools = client.tools("unhealthy_server")
151+
>>> except ServerUnhealthyError as e:
152+
>>> print(f"Server '{e.server_name}' is unhealthy")
153+
>>> if e.health_status:
154+
>>> print(f"Health details: {e.health_status}")
155+
"""
156+
157+
def __init__(self, message: str, server_name: str, health_status: str):
158+
"""Initialize ServerUnhealthyError.
159+
160+
Args:
161+
message: The error message.
162+
server_name: The name of the server that is unhealthy.
163+
health_status: Details about the server's health status.
164+
"""
165+
super().__init__(message)
166+
self.server_name = server_name
167+
self.health_status = health_status
168+
169+
135170
class ToolNotFoundError(McpdError):
136171
"""Raised when a specified tool doesn't exist on a server.
137172

0 commit comments

Comments
 (0)