Skip to content

Commit c874895

Browse files
authored
Add health filtering to agent_tools() method (#32)
* Add health filtering to agent_tools() method - Add check_health parameter to filter tools by server health status - Defaults to True for best performance and JavaScript SDK parity - Add _get_healthy_servers() helper method for batch health checking - Silently skip unhealthy servers (timeout, unreachable, unknown) - Add comprehensive unit tests for health filtering scenarios - Update existing tests to mock server_health - Update README with health filtering examples - Maintains full backward compatibility via check_health=False option
1 parent 494d4bf commit c874895

File tree

4 files changed

+292
-48
lines changed

4 files changed

+292
-48
lines changed

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,18 @@ from mcpd import McpdClient
9090
# Assumes the mcpd daemon is running
9191
client = McpdClient(api_endpoint="http://localhost:8090")
9292

93-
# Get all tools from all servers
93+
# Get all tools from healthy servers (default - filters out unhealthy servers)
9494
all_tools = client.agent_tools()
9595

96-
# Get tools from specific servers only
96+
# Get tools from specific servers, only if healthy
9797
time_tools = client.agent_tools(servers=['time'])
9898

99-
# Get tools from multiple servers
99+
# Get tools from multiple servers, only if healthy
100100
subset_tools = client.agent_tools(servers=['time', 'fetch'])
101101

102+
# Get tools from all servers regardless of health (not recommended)
103+
all_tools_unfiltered = client.agent_tools(check_health=False)
104+
102105
agent_config = AgentConfig(
103106
tools=client.agent_tools(),
104107
model_id="gpt-4.1-nano", # Requires OPENAI_API_KEY to be set
@@ -142,7 +145,7 @@ client = McpdClient(api_endpoint="http://localhost:8090", api_key="optional-key"
142145

143146
* `client.tools(server_name: str) -> list[dict]` - Returns the tool schema definitions for only the specified server.
144147

145-
* `client.agent_tools() -> list[Callable]` - Returns a list of self-contained, callable functions suitable for agentic frameworks.
148+
* `client.agent_tools(servers: list[str] | None = None, *, check_health: bool = True) -> list[Callable]` - Returns a list of self-contained, callable functions suitable for agentic frameworks. By default, filters to healthy servers only. Use `servers` to filter by server names, or `check_health=False` to include all servers regardless of health.
146149

147150
* `client.clear_agent_tools_cache()` - Clears cached generated callable functions that are created when calling agent_tools().
148151

src/mcpd/mcpd_client.py

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -367,13 +367,17 @@ def _get_tool_definitions(self, server_name: str) -> list[dict[str, Any]]:
367367
except requests.exceptions.RequestException as e:
368368
raise McpdError(f"Error listing tool definitions for server '{server_name}': {e}") from e
369369

370-
def agent_tools(self, servers: list[str] | None = None) -> list[Callable[..., Any]]:
370+
def agent_tools(self, servers: list[str] | None = None, *, check_health: bool = True) -> list[Callable[..., Any]]:
371371
"""Generate callable Python functions for all available tools, suitable for AI agents.
372372
373-
This method queries all servers via `tools()` and creates self-contained,
374-
deepcopy-safe functions that can be passed to agentic frameworks like any-agent,
375-
LangChain, or custom AI systems. Each function includes its schema as metadata
376-
and handles the MCP communication internally.
373+
This method queries servers and creates self-contained, deepcopy-safe functions
374+
that can be passed to agentic frameworks like any-agent, LangChain, or custom AI
375+
systems. Each function includes its schema as metadata and handles the MCP
376+
communication internally.
377+
378+
By default, this method automatically filters out unhealthy servers by checking
379+
their health status before fetching tools. Unhealthy servers are silently skipped
380+
to ensure the method returns quickly without waiting for timeouts on failed servers.
377381
378382
The generated functions are cached for performance. Use clear_agent_tools_cache()
379383
to force regeneration if servers or tools have changed.
@@ -384,8 +388,14 @@ def agent_tools(self, servers: list[str] | None = None) -> list[Callable[..., An
384388
If specified, only tools from the listed servers are included.
385389
Non-existent server names are silently ignored.
386390
391+
check_health: Whether to filter to healthy servers only.
392+
If True (default), only returns tools from servers with 'ok' status.
393+
If False, returns tools from all servers regardless of health.
394+
Most users should leave this as True for best performance.
395+
387396
Returns:
388-
A list of callable functions, one for each tool across all servers.
397+
A list of callable functions, one for each tool from healthy servers (if check_health=True, the default)
398+
or all servers (if check_health=False).
389399
Each function has the following attributes:
390400
- __name__: The tool's qualified name (e.g., "time__get_current_time")
391401
- __doc__: The tool's description
@@ -397,23 +407,26 @@ def agent_tools(self, servers: list[str] | None = None) -> list[Callable[..., An
397407
ConnectionError: If unable to connect to the mcpd daemon.
398408
TimeoutError: If requests to the daemon time out.
399409
AuthenticationError: If API key authentication fails.
400-
ServerNotFoundError: If a server becomes unavailable during tool retrieval.
401-
McpdError: If unable to retrieve tool definitions or generate functions.
410+
McpdError: If unable to retrieve server health status (when check_health=True)
411+
or retrieve tool definitions or generate functions.
402412
403413
Example:
404414
>>> from any_agent import AnyAgent, AgentConfig
405415
>>> from mcpd import McpdClient
406416
>>>
407417
>>> client = McpdClient(api_endpoint="http://localhost:8090")
408418
>>>
409-
>>> # Get all tools as callable functions
419+
>>> # Get all tools from healthy servers (default)
410420
>>> tools = client.agent_tools()
411421
>>> print(f"Generated {len(tools)} callable tools")
412422
>>>
413-
>>> # Get tools from specific servers only
423+
>>> # Get tools from specific servers, only if healthy
414424
>>> time_tools = client.agent_tools(servers=['time'])
415425
>>> subset_tools = client.agent_tools(servers=['time', 'fetch'])
416426
>>>
427+
>>> # Get tools from all servers regardless of health (keyword-only argument)
428+
>>> all_tools = client.agent_tools(check_health=False)
429+
>>>
417430
>>> # Use with an AI agent framework
418431
>>> agent_config = AgentConfig(
419432
... tools=tools,
@@ -431,24 +444,57 @@ def agent_tools(self, servers: list[str] | None = None) -> list[Callable[..., An
431444
but may not be suitable for pickling due to the embedded client state.
432445
"""
433446
agent_tools = []
434-
all_tools = self.tools()
435447

436448
# Determine which servers to use.
437-
servers_to_use = all_tools.keys() if servers is None else servers
449+
servers_to_use = self.servers() if servers is None else servers
438450

439-
# Fetch tools from selected servers.
451+
# Filter to healthy servers if requested (one HTTP call for all servers).
452+
if check_health:
453+
servers_to_use = self._get_healthy_servers(servers_to_use)
454+
455+
# Fetch tools from selected servers only (avoids fetching from unhealthy servers).
440456
for server_name in servers_to_use:
441-
if server_name not in all_tools:
442-
# Server doesn't exist or has no tools - skip silently.
457+
try:
458+
tool_schemas = self.tools(server_name=server_name)
459+
except (ServerNotFoundError, ServerUnhealthyError):
460+
# Server doesn't exist or became unhealthy - skip silently.
443461
continue
444462

445-
tool_schemas = all_tools[server_name]
446463
for tool_schema in tool_schemas:
447464
func = self._function_builder.create_function_from_schema(tool_schema, server_name)
448465
agent_tools.append(func)
449466

450467
return agent_tools
451468

469+
def _get_healthy_servers(self, server_names: list[str]) -> list[str]:
470+
"""Filter server names to only those that are healthy.
471+
472+
Args:
473+
server_names: List of server names to filter.
474+
475+
Returns:
476+
List of server names that have health status 'ok'.
477+
478+
Raises:
479+
McpdError: If unable to retrieve server health information.
480+
481+
Note:
482+
This method silently skips servers that don't exist or have
483+
unhealthy status (timeout, unreachable, unknown).
484+
"""
485+
if not server_names:
486+
return []
487+
488+
health_map = self.server_health()
489+
490+
healthy_servers = [
491+
name
492+
for name in server_names
493+
if name in health_map and HealthStatus.is_healthy(health_map[name].get("status"))
494+
]
495+
496+
return healthy_servers
497+
452498
def has_tool(self, server_name: str, tool_name: str) -> bool:
453499
"""Check if a specific tool exists on a given server.
454500

tests/conftest.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Callable
12
from unittest.mock import Mock
23

34
import pytest
@@ -55,3 +56,28 @@ def client(fqdn):
5556
@pytest.fixture(scope="function")
5657
def client_with_auth(fqdn):
5758
return McpdClient(api_endpoint=fqdn, api_key="test-key") # pragma: allowlist secret
59+
60+
61+
@pytest.fixture
62+
def tools_side_effect():
63+
"""Factory for creating tools() mock side effects.
64+
65+
Returns a function that creates side_effect functions for mocking tools().
66+
The side_effect returns the appropriate tool list based on server_name parameter.
67+
68+
Usage:
69+
def test_something(tools_side_effect):
70+
tools_map = {
71+
"server1": [{"name": "tool1", "description": "Tool 1"}],
72+
"server2": [{"name": "tool2", "description": "Tool 2"}],
73+
}
74+
mock_tools.side_effect = tools_side_effect(tools_map)
75+
"""
76+
77+
def _create_side_effect(tools_map: dict[str, list[dict]]) -> Callable[[str | None], list[dict]]:
78+
def side_effect(server_name: str | None = None) -> list[dict]:
79+
return tools_map.get(server_name, [])
80+
81+
return side_effect
82+
83+
return _create_side_effect

0 commit comments

Comments
 (0)