Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,18 @@ from mcpd import McpdClient
# Assumes the mcpd daemon is running
client = McpdClient(api_endpoint="http://localhost:8090")

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

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

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

# Get tools from all servers regardless of health (not recommended)
all_tools_unfiltered = client.agent_tools(check_health=False)

agent_config = AgentConfig(
tools=client.agent_tools(),
model_id="gpt-4.1-nano", # Requires OPENAI_API_KEY to be set
Expand Down Expand Up @@ -142,7 +145,7 @@ client = McpdClient(api_endpoint="http://localhost:8090", api_key="optional-key"

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

* `client.agent_tools() -> list[Callable]` - Returns a list of self-contained, callable functions suitable for agentic frameworks.
* `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.

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

Expand Down
78 changes: 62 additions & 16 deletions src/mcpd/mcpd_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,13 +367,17 @@ def _get_tool_definitions(self, server_name: str) -> list[dict[str, Any]]:
except requests.exceptions.RequestException as e:
raise McpdError(f"Error listing tool definitions for server '{server_name}': {e}") from e

def agent_tools(self, servers: list[str] | None = None) -> list[Callable[..., Any]]:
def agent_tools(self, servers: list[str] | None = None, *, check_health: bool = True) -> list[Callable[..., Any]]:
"""Generate callable Python functions for all available tools, suitable for AI agents.

This method queries all servers via `tools()` and creates self-contained,
deepcopy-safe functions that can be passed to agentic frameworks like any-agent,
LangChain, or custom AI systems. Each function includes its schema as metadata
and handles the MCP communication internally.
This method queries servers and creates self-contained, deepcopy-safe functions
that can be passed to agentic frameworks like any-agent, LangChain, or custom AI
systems. Each function includes its schema as metadata and handles the MCP
communication internally.

By default, this method automatically filters out unhealthy servers by checking
their health status before fetching tools. Unhealthy servers are silently skipped
to ensure the method returns quickly without waiting for timeouts on failed servers.

The generated functions are cached for performance. Use clear_agent_tools_cache()
to force regeneration if servers or tools have changed.
Expand All @@ -384,8 +388,14 @@ def agent_tools(self, servers: list[str] | None = None) -> list[Callable[..., An
If specified, only tools from the listed servers are included.
Non-existent server names are silently ignored.

check_health: Whether to filter to healthy servers only.
If True (default), only returns tools from servers with 'ok' status.
If False, returns tools from all servers regardless of health.
Most users should leave this as True for best performance.

Returns:
A list of callable functions, one for each tool across all servers.
A list of callable functions, one for each tool from healthy servers (if check_health=True, the default)
or all servers (if check_health=False).
Each function has the following attributes:
- __name__: The tool's qualified name (e.g., "time__get_current_time")
- __doc__: The tool's description
Expand All @@ -397,23 +407,26 @@ def agent_tools(self, servers: list[str] | None = None) -> list[Callable[..., An
ConnectionError: If unable to connect to the mcpd daemon.
TimeoutError: If requests to the daemon time out.
AuthenticationError: If API key authentication fails.
ServerNotFoundError: If a server becomes unavailable during tool retrieval.
McpdError: If unable to retrieve tool definitions or generate functions.
McpdError: If unable to retrieve server health status (when check_health=True)
or retrieve tool definitions or generate functions.

Example:
>>> from any_agent import AnyAgent, AgentConfig
>>> from mcpd import McpdClient
>>>
>>> client = McpdClient(api_endpoint="http://localhost:8090")
>>>
>>> # Get all tools as callable functions
>>> # Get all tools from healthy servers (default)
>>> tools = client.agent_tools()
>>> print(f"Generated {len(tools)} callable tools")
>>>
>>> # Get tools from specific servers only
>>> # Get tools from specific servers, only if healthy
>>> time_tools = client.agent_tools(servers=['time'])
>>> subset_tools = client.agent_tools(servers=['time', 'fetch'])
>>>
>>> # Get tools from all servers regardless of health (keyword-only argument)
>>> all_tools = client.agent_tools(check_health=False)
>>>
>>> # Use with an AI agent framework
>>> agent_config = AgentConfig(
... tools=tools,
Expand All @@ -431,24 +444,57 @@ def agent_tools(self, servers: list[str] | None = None) -> list[Callable[..., An
but may not be suitable for pickling due to the embedded client state.
"""
agent_tools = []
all_tools = self.tools()

# Determine which servers to use.
servers_to_use = all_tools.keys() if servers is None else servers
servers_to_use = self.servers() if servers is None else servers

# Fetch tools from selected servers.
# Filter to healthy servers if requested (one HTTP call for all servers).
if check_health:
servers_to_use = self._get_healthy_servers(servers_to_use)

# Fetch tools from selected servers only (avoids fetching from unhealthy servers).
for server_name in servers_to_use:
if server_name not in all_tools:
# Server doesn't exist or has no tools - skip silently.
try:
tool_schemas = self.tools(server_name=server_name)
except (ServerNotFoundError, ServerUnhealthyError):
# Server doesn't exist or became unhealthy - skip silently.
continue

tool_schemas = all_tools[server_name]
for tool_schema in tool_schemas:
func = self._function_builder.create_function_from_schema(tool_schema, server_name)
agent_tools.append(func)

return agent_tools

def _get_healthy_servers(self, server_names: list[str]) -> list[str]:
"""Filter server names to only those that are healthy.

Args:
server_names: List of server names to filter.

Returns:
List of server names that have health status 'ok'.

Raises:
McpdError: If unable to retrieve server health information.

Note:
This method silently skips servers that don't exist or have
unhealthy status (timeout, unreachable, unknown).
"""
if not server_names:
return []

health_map = self.server_health()

healthy_servers = [
name
for name in server_names
if name in health_map and HealthStatus.is_healthy(health_map[name].get("status"))
]

return healthy_servers

def has_tool(self, server_name: str, tool_name: str) -> bool:
"""Check if a specific tool exists on a given server.

Expand Down
26 changes: 26 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections.abc import Callable
from unittest.mock import Mock

import pytest
Expand Down Expand Up @@ -55,3 +56,28 @@ def client(fqdn):
@pytest.fixture(scope="function")
def client_with_auth(fqdn):
return McpdClient(api_endpoint=fqdn, api_key="test-key") # pragma: allowlist secret


@pytest.fixture
def tools_side_effect():
"""Factory for creating tools() mock side effects.

Returns a function that creates side_effect functions for mocking tools().
The side_effect returns the appropriate tool list based on server_name parameter.

Usage:
def test_something(tools_side_effect):
tools_map = {
"server1": [{"name": "tool1", "description": "Tool 1"}],
"server2": [{"name": "tool2", "description": "Tool 2"}],
}
mock_tools.side_effect = tools_side_effect(tools_map)
"""

def _create_side_effect(tools_map: dict[str, list[dict]]) -> Callable[[str | None], list[dict]]:
def side_effect(server_name: str | None = None) -> list[dict]:
return tools_map.get(server_name, [])

return side_effect

return _create_side_effect
Loading