From 40cb1162754da67ec71e43d6c6c751a4a3e5d141 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 11 Aug 2025 20:12:26 +0100 Subject: [PATCH] Docstrings and exceptions * Add exception hierarchy * Add detailed docstrings (and improve type hinting on function signatures) * Make as much private as possible --- src/mcpd/__init__.py | 19 +- src/mcpd/dynamic_caller.py | 151 +++++++++- src/mcpd/exceptions.py | 243 ++++++++++++++- src/mcpd/function_builder.py | 438 +++++++++++++++++++++++++++- src/mcpd/mcpd_client.py | 373 +++++++++++++++++++++-- tests/unit/test_dynamic_caller.py | 26 +- tests/unit/test_exceptions.py | 285 +++++++++++++++++- tests/unit/test_function_builder.py | 10 +- tests/unit/test_mcpd_client.py | 16 +- 9 files changed, 1480 insertions(+), 81 deletions(-) diff --git a/src/mcpd/__init__.py b/src/mcpd/__init__.py index 8ef98cc..1766d47 100644 --- a/src/mcpd/__init__.py +++ b/src/mcpd/__init__.py @@ -1,6 +1,23 @@ -from .mcpd_client import McpdClient, McpdError +from .exceptions import ( + AuthenticationError, + ConnectionError, + McpdError, + ServerNotFoundError, + TimeoutError, + ToolExecutionError, + ToolNotFoundError, + ValidationError, +) +from .mcpd_client import McpdClient __all__ = [ "McpdClient", "McpdError", + "AuthenticationError", + "ConnectionError", + "ServerNotFoundError", + "TimeoutError", + "ToolExecutionError", + "ToolNotFoundError", + "ValidationError", ] diff --git a/src/mcpd/dynamic_caller.py b/src/mcpd/dynamic_caller.py index 2c1721b..6bf1204 100644 --- a/src/mcpd/dynamic_caller.py +++ b/src/mcpd/dynamic_caller.py @@ -1,30 +1,157 @@ -from .exceptions import McpdError +from .exceptions import McpdError, ToolNotFoundError class DynamicCaller: - """Helper class to enable client.call..(**kwargs) syntax.""" + """ + Enables dynamic, attribute-based tool invocation using natural Python syntax. - def __init__(self, client): + This class provides the magic behind the client.call..(**kwargs) syntax, + allowing you to call MCP tools as if they were native Python methods. It uses Python's + __getattr__ to dynamically resolve server and tool names at runtime. + + The DynamicCaller is automatically instantiated as the 'call' attribute on McpdClient + and should not be created directly. + + Attributes: + _client: Reference to the parent McpdClient instance. + + Example: + >>> client = McpdClient(api_endpoint="http://localhost:8090") + >>> + >>> # Access tools through natural attribute syntax + >>> # Instead of: client._perform_call("time", "get_current_time", {"timezone": "UTC"}) + >>> # You can write: + >>> result = client.call.time.get_current_time(timezone="UTC") + >>> + >>> # Works with any server and tool name + >>> weather = client.call.duckduckgo_mcp.searsch(query="Tokyo", max_results=3) + >>> commits = client.call.mcp_discord.discord_read_messages(channelId="9223372036854775806", limit=10) + + Note: + Tool and server names are resolved at runtime. If a server or tool doesn't exist, + an McpdError will be raised when you attempt to call it. Use client.has_tool() + to check availability before calling if needed. + """ + + def __init__(self, client: "McpdClient"): + """ + Initialize the DynamicCaller with a reference to the client. + + Args: + client: The McpdClient instance that owns this DynamicCaller. + """ self._client = client - def __getattr__(self, server_name: str): - """Get a server proxy for dynamic tool calling.""" + def __getattr__(self, server_name: str) -> "ServerProxy": + """ + Create a ServerProxy for the specified server name. + + This method is called when accessing an attribute on the DynamicCaller, + e.g., client.call.time returns a ServerProxy for the "time" server. + + Args: + server_name: The name of the MCP server to create a proxy for. + + Returns: + A ServerProxy instance that can be used to call tools on that server. + + Example: + >>> # When you write: client.call.time + >>> # Python calls: client.call.__getattr__("time") + >>> # Which returns: ServerProxy(client, "time") + """ return ServerProxy(self._client, server_name) class ServerProxy: - """Proxy class for server-specific tool calling.""" + """ + Proxy for a specific MCP server, enabling tool invocation via attributes. + + This class represents a specific MCP server and allows calling its tools + as if they were methods. It's created automatically by DynamicCaller and + should not be instantiated directly. + + Attributes: + _client: Reference to the McpdClient instance. + _server_name: Name of the MCP server this proxy represents. - def __init__(self, client, server_name: str): + Example: + >>> # ServerProxy is created when you access a server: + >>> time_server = client.call.time # Returns ServerProxy(client, "time") + >>> + >>> # You can then call tools on it: + >>> current_time = time_server.get_current_time(timezone="UTC") + >>> + >>> # Or chain it directly: + >>> current_time = client.call.time.get_current_time(timezone="UTC") + """ + + def __init__(self, client: "McpdClient", server_name: str): + """ + Initialize a ServerProxy for a specific server. + + Args: + client: The McpdClient instance to use for API calls. + server_name: The name of the MCP server this proxy represents. + """ self._client = client self._server_name = server_name - def __getattr__(self, tool_name: str): - """Get a tool callable.""" + def __getattr__(self, tool_name: str) -> callable: + """ + Create a callable function for the specified tool. + + When you access an attribute on a ServerProxy (e.g., time_server.get_current_time), + this method creates and returns a function that will call that tool when invoked. + + Args: + tool_name: The name of the tool to create a callable for. + + Returns: + A callable function that accepts keyword arguments and invokes the tool. + + Raises: + McpdError: If the tool doesn't exist on this server. + + Example: + >>> # When you write: client.call.time.get_current_time + >>> # Python calls: ServerProxy.__getattr__("get_current_time") + >>> # Which returns a function that calls the tool + >>> + >>> # The returned function can then be called: + >>> result = client.call.time.get_current_time(timezone="UTC") + >>> + >>> # You can also store the function reference: + >>> get_time = client.call.time.get_current_time + >>> tokyo_time = get_time(timezone="Asia/Tokyo") + >>> london_time = get_time(timezone="Europe/London") + """ if not self._client.has_tool(self._server_name, tool_name): - raise McpdError(f"Tool '{tool_name}' not found on server '{self._server_name}'") + raise ToolNotFoundError( + f"Tool '{tool_name}' not found on server '{self._server_name}'. " + f"Use client.tools('{self._server_name}') to see available tools.", + server_name=self._server_name, + tool_name=tool_name, + ) + + def tool_function(**kwargs): + """ + Execute the MCP tool with the provided parameters. + + Args: + **kwargs: Tool parameters as keyword arguments. + These should match the tool's inputSchema. - def tool_callable(**kwargs): + Returns: + The tool's response, typically a dictionary with the results. + + Raises: + McpdError: If the tool execution fails for any reason. + """ return self._client._perform_call(self._server_name, tool_name, kwargs) - return tool_callable + # Add metadata to help with debugging and introspection + tool_function.__name__ = f"{self._server_name}__{tool_name}" + tool_function.__qualname__ = f"ServerProxy.{tool_name}" + + return tool_function diff --git a/src/mcpd/exceptions.py b/src/mcpd/exceptions.py index 682b0b5..85622b3 100644 --- a/src/mcpd/exceptions.py +++ b/src/mcpd/exceptions.py @@ -1,4 +1,245 @@ +""" +Exception hierarchy for the mcpd SDK. + +This module provides a structured exception hierarchy to help users handle +different error scenarios appropriately. +""" + + class McpdError(Exception): - """Base exception for MCP SDK errors.""" + """ + Base exception for all mcpd SDK errors. + + This exception wraps all errors that occur during interaction with the mcpd daemon, + including network failures, authentication errors, server errors, and tool execution + failures. The original exception is preserved via exception chaining for debugging. + + Common error scenarios: + - Network connectivity issues with the mcpd daemon + - Authentication failures (invalid or missing API key) + - Server not found or unavailable + - Tool not found on the specified server + - Tool execution errors (invalid parameters, server-side failures) + - Timeout errors during long-running operations + + Attributes: + args: The error message and any additional arguments. + __cause__: The original exception that triggered this error (if any). + + Example: + >>> from mcpd import McpdClient, McpdError + >>> + >>> client = McpdClient(api_endpoint="http://localhost:8090") + >>> + >>> try: + >>> # Attempt to call a tool that might not exist + >>> result = client.call.unknown_server.unknown_tool() + >>> except McpdError as e: + >>> print(f"Operation failed: {e}") + >>> # Access the original exception for debugging + >>> if e.__cause__: + >>> print(f"Underlying cause: {e.__cause__}") + >>> + >>> # Handle specific error scenarios + >>> try: + >>> servers = client.servers() + >>> except McpdError as e: + >>> if "authentication" in str(e).lower(): + >>> print("Authentication failed - check your API key") + >>> elif "connection" in str(e).lower(): + >>> print("Cannot reach mcpd daemon - is it running?") + >>> else: + >>> print(f"Unexpected error: {e}") + + Note: + When catching McpdError, you can access the original exception through + the `__cause__` attribute for more detailed error handling or logging. + All SDK methods that interact with the mcpd daemon will raise McpdError + or one of its subclasses on failure, providing a consistent error interface. + """ pass + + +class ConnectionError(McpdError): + """ + Raised when unable to connect to the mcpd daemon. + + This typically indicates that: + - The mcpd daemon is not running + - The endpoint URL is incorrect + - Network connectivity issues + - Firewall blocking the connection + + Example: + >>> try: + >>> client = McpdClient(api_endpoint="http://localhost:8090") + >>> servers = client.servers() + >>> except ConnectionError as e: + >>> print("Cannot reach mcpd daemon - is it running?") + >>> print("Try running: mcpd start") + """ + + pass + + +class AuthenticationError(McpdError): + """ + Raised when authentication with the mcpd daemon fails. + + This indicates that: + - The API key is invalid or expired + - The API key is missing but required + - The authentication method is not supported + + Example: + >>> try: + >>> client = McpdClient( + >>> api_endpoint="http://localhost:8090", + >>> api_key="invalid-key" # pragma: allowlist secret + >>> ) + >>> servers = client.servers() + >>> except AuthenticationError as e: + >>> print("Authentication failed - check your API key") + """ + + pass + + +class ServerNotFoundError(McpdError): + """ + Raised when a specified MCP server doesn't exist. + + This error occurs when trying to access a server that: + - Is not configured in the mcpd daemon + - Has been removed or renamed + - Is temporarily unavailable + + Attributes: + server_name: The name of the server that wasn't found. + + Example: + >>> try: + >>> tools = client.tools("nonexistent_server") + >>> except ServerNotFoundError as e: + >>> print(f"Server '{e.server_name}' not found") + >>> print(f"Available servers: {client.servers()}") + """ + + def __init__(self, message: str, server_name: str = None): + super().__init__(message) + self.server_name = server_name + + +class ToolNotFoundError(McpdError): + """ + Raised when a specified tool doesn't exist on a server. + + This error occurs when trying to call a tool that: + - Doesn't exist on the specified server + - Has been removed or renamed + - Is temporarily unavailable + + Attributes: + server_name: The name of the server. + tool_name: The name of the tool that wasn't found. + + Example: + >>> try: + >>> result = client.call.time.nonexistent_tool() + >>> except ToolNotFoundError as e: + >>> print(f"Tool '{e.tool_name}' not found on server '{e.server_name}'") + >>> tools = client.tools(e.server_name) + >>> print(f"Available tools: {[t['name'] for t in tools]}") + """ + + def __init__(self, message: str, server_name: str = None, tool_name: str = None): + super().__init__(message) + self.server_name = server_name + self.tool_name = tool_name + + +class ToolExecutionError(McpdError): + """ + Raised when a tool execution fails on the server side. + + This indicates that the tool was found and called, but failed during execution: + - Invalid parameters provided + - Server-side error during tool execution + - Tool returned an error response + - Timeout during tool execution + + Attributes: + server_name: The name of the server. + tool_name: The name of the tool that failed. + details: Additional error details from the server (if available). + + Example: + >>> try: + >>> # Call with invalid parameters + >>> result = client.call.filesystem.read_file(path="/nonexistent/file") + >>> except ToolExecutionError as e: + >>> print(f"Tool execution failed: {e}") + >>> if e.details: + >>> print(f"Server error details: {e.details}") + """ + + def __init__(self, message: str, server_name: str = None, tool_name: str = None, details: dict = None): + super().__init__(message) + self.server_name = server_name + self.tool_name = tool_name + self.details = details + + +class ValidationError(McpdError): + """ + Raised when input validation fails. + + This occurs when: + - Required parameters are missing + - Parameter types don't match the schema + - Parameter values don't meet constraints + + Attributes: + validation_errors: List of specific validation failures. + + Example: + >>> try: + >>> # Missing required parameter + >>> result = client.call.database.query() # 'sql' parameter required + >>> except ValidationError as e: + >>> print(f"Validation failed: {e}") + >>> for error in e.validation_errors: + >>> print(f" - {error}") + """ + + def __init__(self, message: str, validation_errors: list = None): + super().__init__(message) + self.validation_errors = validation_errors or [] + + +class TimeoutError(McpdError): + """ + Raised when an operation times out. + + This can occur during: + - Long-running tool executions + - Slow network connections + - Unresponsive mcpd daemon + + Attributes: + operation: Description of the operation that timed out. + timeout: The timeout value in seconds. + + Example: + >>> try: + >>> # Long-running operation + >>> result = client.call.analysis.process_large_dataset(data=huge_data) + >>> except TimeoutError as e: + >>> print(f"Operation timed out after {e.timeout} seconds: {e.operation}") + """ + + def __init__(self, message: str, operation: str = None, timeout: float = None): + super().__init__(message) + self.operation = operation + self.timeout = timeout diff --git a/src/mcpd/function_builder.py b/src/mcpd/function_builder.py index 4569398..2039879 100644 --- a/src/mcpd/function_builder.py +++ b/src/mcpd/function_builder.py @@ -2,25 +2,163 @@ from types import FunctionType from typing import Any -from .exceptions import McpdError +from .exceptions import McpdError, ValidationError from .type_converter import TypeConverter class FunctionBuilder: - """Builds callable functions from JSON schemas using string compilation.""" - - def __init__(self, client): - self.client = client + """ + Builds callable Python functions from MCP tool JSON schemas. + + This class generates self-contained functions that can be used with AI agent + frameworks. It uses dynamic string compilation to create functions with proper + parameter validation, type annotations, and docstrings based on the tool's + JSON Schema definition. + + The generated functions are cached for performance, with cache invalidation + controlled by the owning McpdClient via clear_cache(). + + Attributes: + _client: Reference to the McpdClient instance for tool execution. + _function_cache: Cache of compiled function templates and metadata. + + Example: + This class is typically used internally by McpdClient.agent_tools(): + + >>> # Internal usage (not typical user code): + >>> builder = FunctionBuilder(client) + >>> schema = {"name": "get_time", "inputSchema": {...}} + >>> func = builder.create_function_from_schema(schema, "time_server") + >>> result = func(timezone="UTC") # Executes the MCP tool + """ + + def __init__(self, client: "McpdClient"): + """ + Initialize a FunctionBuilder for the given client. + + Args: + client: The McpdClient instance that will be used to execute + the generated functions via _perform_call(). + """ + self._client = client self._function_cache = {} def _safe_name(self, name: str) -> str: + """ + Convert a string into a safe Python identifier. + + This method sanitizes arbitrary strings (like server names or tool names) to create + valid Python identifiers that can be used as function names or variable names. + It replaces non-word characters and handles edge cases like leading digits. + + Args: + name: The string to convert into a safe identifier. Can contain any characters. + + Returns: + A string that is a valid Python identifier: + - Contains only letters, digits, and underscores + - Does not start with a digit + - Non-word characters are replaced with underscores + + Example: + >>> builder._safe_name("my-server") + 'my_server' + >>> builder._safe_name("123tool") + '_123tool' + >>> builder._safe_name("special@chars!") + 'special_chars_' + >>> builder._safe_name("valid_name") + 'valid_name' + """ return re.sub(r"\W|^(?=\d)", "_", name) # replace non‑word chars, leading digit def _function_name(self, server_name: str, schema_name: str) -> str: + """ + Generate a unique function name from server and tool names. + + This method creates a qualified function name by combining the server name + and tool name with a double underscore separator. Both names are sanitized + using _safe_name() to ensure the result is a valid Python identifier. + + The double underscore convention helps distinguish the server and tool + components while maintaining uniqueness across the entire function namespace. + + Args: + server_name: The name of the MCP server hosting the tool. + schema_name: The name of the tool from the schema definition. + + Returns: + A qualified function name in the format "{safe_server}__{safe_tool}". + The result is guaranteed to be a valid Python identifier. + + Example: + >>> builder._function_name("time-server", "get_current_time") + 'time_server__get_current_time' + >>> builder._function_name("my@server", "tool-123") + 'my_server__tool_123' + >>> builder._function_name("simple", "tool") + 'simple__tool' + + Note: + This naming convention allows the generated function to be introspected + to determine its originating server and tool names by splitting on '__'. + """ return f"{self._safe_name(server_name)}__{self._safe_name(schema_name)}" def create_function_from_schema(self, schema: dict[str, Any], server_name: str) -> FunctionType: - """Create a callable function from a JSON schema.""" + """ + Create a callable Python function from an MCP tool's JSON Schema definition. + + This method generates a self-contained, callable function that validates parameters + and executes the corresponding MCP tool. The function is dynamically compiled from + a string template and includes proper type annotations, docstrings, and validation + logic based on the tool's JSON Schema. + + Generated functions are cached for performance. If a function for the same + server/tool combination already exists in the cache, it returns a new instance + of the cached function template rather than recompiling. + + Args: + schema: The MCP tool's JSON Schema definition, containing: + - 'name': Tool identifier (required) + - 'description': Human-readable description (optional) + - 'inputSchema': JSON Schema for parameters (optional) + server_name: The name of the MCP server hosting this tool. + + Returns: + A callable Python function with the following characteristics: + - Parameter signature matches the tool's inputSchema + - Required parameters first, then optional parameters with defaults + - Type annotations based on JSON Schema types + - Comprehensive docstring with parameter descriptions + - Raises ValidationError for missing required parameters + - Returns the tool's execution result via client._perform_call() + + Raises: + McpdError: If function compilation fails due to invalid schema, + malformed tool definition, or code generation errors. + The original exception is preserved via exception chaining. + + Example: + >>> schema = { + ... "name": "get_current_time", + ... "description": "Get the current time in a timezone", + ... "inputSchema": { + ... "type": "object", + ... "properties": { + ... "timezone": {"type": "string", "description": "IANA timezone"} + ... }, + ... "required": ["timezone"] + ... } + ... } + >>> func = builder.create_function_from_schema(schema, "time_server") + >>> result = func(timezone="UTC") # Calls the MCP tool + + Note: + The generated function includes validation logic that checks for required + parameters at runtime and builds a parameters dictionary for the API call. + The function is cached using a key of "{server_name}__{tool_name}". + """ cache_key = f"{server_name}__{schema.get('name', '')}" if cache_key in self._function_cache: @@ -40,7 +178,7 @@ def create_function_from_schema(self, schema: dict[str, Any], server_name: str) created_function.__annotations__ = annotations # Cache the function creation details - def create_function_instance(annotations): + def create_function_instance(annotations: dict[str, Any]) -> FunctionType: temp_namespace = namespace.copy() exec(compiled_code, temp_namespace) new_func = temp_namespace[function_name] @@ -59,7 +197,88 @@ def create_function_instance(annotations): raise McpdError(f"Error creating function {cache_key}: {e}") from e def _build_function_code(self, schema: dict[str, Any], server_name: str) -> str: - """Build the function code string.""" + """ + Generate Python function source code from an MCP tool's JSON Schema. + + This method is the core of the dynamic function generation system. It creates + a complete Python function as a string that includes parameter validation, + proper signature ordering, docstring generation, and API call execution. + + The generated function includes: + - Required parameters first, then optional parameters with None defaults + - Runtime validation that raises ValidationError for missing required params + - Parameter dictionary building that excludes None values + - Direct call to client._perform_call() with the tool's server and name + + Args: + schema: The MCP tool's JSON Schema definition containing: + - 'name': Tool identifier (required) + - 'description': Tool description (optional) + - 'inputSchema': Parameter schema with 'properties' and 'required' (optional) + server_name: The name of the MCP server hosting this tool. + + Returns: + Complete Python function source code as a string, ready for compilation + with compile() and execution with exec(). The function will be named + using the _function_name() convention. + + Example: + Given a schema like: + ```python + { + "name": "get_time", + "description": "Get current time", + "inputSchema": { + "properties": {"timezone": {"type": "string"}}, + "required": ["timezone"] + } + } + ``` + + This method generates code equivalent to: + ```python + def server__get_time(timezone): + '''Get current time + + Args: + timezone: No description provided + + Returns: + Any: Function execution result + + Raises: + ValidationError: If required parameters are missing + McpdError: If the API call fails + ''' + # Validate required parameters + required_params = ['timezone'] + missing_params = [] + locals_dict = locals() + + for param in required_params: + if param not in locals_dict or locals_dict[param] is None: + missing_params.append(param) + + if missing_params: + raise ValidationError(f"Missing required parameters: {missing_params}", validation_errors=missing_params) + + # Build parameters dictionary + params = {} + locals_dict = locals() + + for param_name in ['timezone']: + if param_name in locals_dict and locals_dict[param_name] is not None: + params[param_name] = locals_dict[param_name] + + # Make the API call + return client._perform_call("server", "get_time", params) + ``` + + Note: + The generated code uses string interpolation and list literals to embed + the schema data directly into the function code. This creates a completely + self-contained function that doesn't depend on the original schema object. + """ function_name = self._function_name(server_name, schema["name"]) input_schema = schema.get("inputSchema", {}) properties = input_schema.get("properties", {}) @@ -95,7 +314,7 @@ def _build_function_code(self, schema: dict[str, Any], server_name: str) -> str: " missing_params.append(param)", "", " if missing_params:", - ' raise McpdError(f"Missing required parameters: {missing_params}")', + ' raise ValidationError(f"Missing required parameters: {missing_params}", validation_errors=missing_params)', "", " # Build parameters dictionary", " params = {}", @@ -112,7 +331,61 @@ def _build_function_code(self, schema: dict[str, Any], server_name: str) -> str: return "\n".join(function_lines) def _create_annotations(self, schema: dict[str, Any]) -> dict[str, Any]: - """Create type annotations for the function.""" + """ + Generate Python type annotations from a tool's JSON Schema. + + This method converts JSON Schema type definitions into Python type hints + that are attached to the generated function. It uses the TypeConverter + utility to handle complex schema types and properly marks optional + parameters with Union[type, None] notation. + + The method processes each parameter in the schema's properties, determines + if it's required, and creates appropriate type annotations. Required + parameters get direct type annotations while optional parameters are + automatically unioned with None. + + Args: + schema: The MCP tool's JSON Schema definition containing: + - 'inputSchema': Object with 'properties' and 'required' arrays + - Properties define parameter names and their JSON Schema types + - Required array lists which parameters are mandatory + + Returns: + A dictionary mapping parameter names to Python type objects, plus + a 'return' key mapped to Any. This dictionary is directly assignable + to a function's __annotations__ attribute. + + Example: + Given a schema like: + ```python + { + "inputSchema": { + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "active": {"type": "boolean"} + }, + "required": ["name"] + } + } + ``` + + Returns annotations equivalent to: + ```python + { + "name": str, # Required parameter + "age": int | None, # Optional parameter + "active": bool | None, # Optional parameter + "return": Any # Return type + } + ``` + + Note: + - Uses TypeConverter.parse_schema_type() for complex type parsing + - Always adds 'return': Any since MCP tool responses are untyped + - Optional parameters use Python 3.10+ union syntax (type | None) + - The annotations are used for IDE support and runtime introspection + """ annotations = {} input_schema = schema.get("inputSchema", {}) properties = input_schema.get("properties", {}) @@ -131,7 +404,71 @@ def _create_annotations(self, schema: dict[str, Any]) -> dict[str, Any]: return annotations def _create_docstring(self, schema: dict[str, Any]) -> str: - """Create a docstring for the function.""" + """ + Generate a comprehensive docstring for the dynamically created function. + + This method builds a properly formatted Python docstring that includes the + tool's description, parameter documentation with optional/required status, + return value information, and exception documentation. The generated docstring + follows Google/NumPy style conventions for consistency. + + The docstring is embedded directly into the generated function code and + provides runtime documentation accessible via help() or __doc__. + + Args: + schema: The MCP tool's JSON Schema definition containing: + - 'description': Human-readable tool description (optional) + - 'inputSchema': Schema with 'properties' and 'required' arrays + - Properties include parameter descriptions + + Returns: + A multi-line string containing the complete docstring text, properly + formatted with sections for description, arguments, returns, and raises. + The string is ready to be embedded in triple quotes in the generated code. + + Example: + Given a schema like: + ```python + { + "description": "Search for items in database", + "inputSchema": { + "properties": { + "query": { + "type": "string", + "description": "Search query string" + }, + "limit": { + "type": "integer", + "description": "Maximum results to return" + } + }, + "required": ["query"] + } + } + ``` + + Generates a docstring like: + ``` + Search for items in database + + Args: + query: Search query string + limit: Maximum results to return (optional) + + Returns: + Any: Function execution result + + Raises: + ValidationError: If required parameters are missing + McpdError: If the API call fails + ``` + + Note: + - Parameters without descriptions get "No description provided" + - Optional parameters are marked with "(optional)" suffix + - The Raises section accurately documents both validation and execution errors + - Empty properties result in a docstring without an Args section + """ description = schema.get("description", "No description provided") input_schema = schema.get("inputSchema", {}) properties = input_schema.get("properties", {}) @@ -156,20 +493,63 @@ def _create_docstring(self, schema: dict[str, Any]) -> str: " Any: Function execution result", "", "Raises:", - " McpdError: If required parameters are missing or API call fails", + " ValidationError: If required parameters are missing", + " McpdError: If the API call fails", ] ) return "\n".join(docstring_parts) def _create_namespace(self) -> dict[str, Any]: - """Create the namespace for function execution.""" + """ + Create the execution namespace for dynamically generated functions. + + This method builds a dictionary containing all the Python built-ins, types, + and references that the generated function code needs at runtime. The namespace + is used as the global scope when executing the compiled function code via exec(). + + The namespace includes: + - Exception classes for error handling (McpdError, ValidationError) + - Reference to the client instance for making API calls + - Python built-in types (str, int, float, bool, list, dict) + - Type annotation utilities (Any, Literal, Union, NoneType) + + This ensures the generated function has access to everything it needs without + relying on module imports or the surrounding scope. + + Returns: + A dictionary mapping names to Python objects, suitable for use as the + globals parameter in exec(). This namespace makes the generated function + completely self-contained. + + Example: + The namespace allows generated code like this to work: + ```python + def server__tool(param: str = None): + # Uses ValidationError from namespace + if missing_params: + raise ValidationError(...) + + # Uses 'client' from namespace + return client._perform_call(...) + ``` + + Without the namespace, the function would fail with NameError + when trying to access ValidationError or client. + + Note: + - The client reference is captured at FunctionBuilder creation time + - All standard Python types are included to support type annotations + - NoneType is imported locally to avoid top-level import issues + - The namespace is copied for each function instance to ensure isolation + """ from types import NoneType from typing import Any, Literal, Union return { "McpdError": McpdError, - "client": self.client, + "ValidationError": ValidationError, + "client": self._client, "Any": Any, "str": str, "int": int, @@ -182,6 +562,32 @@ def _create_namespace(self) -> dict[str, Any]: "NoneType": NoneType, } - def clear_cache(self): - """Clear the function cache.""" + def clear_cache(self) -> None: + """ + Clear the internal function compilation cache. + + This method removes all cached function templates created by previous calls + to create_function_from_schema(). After clearing, subsequent calls to + create_function_from_schema() will recompile functions from their JSON + schemas rather than using cached templates. + + Use this method when: + - MCP server tool definitions have changed + - You want to force regeneration of function wrappers + - Memory usage from cached functions becomes a concern + + The cache is automatically populated as functions are generated, so there's + no need to explicitly populate it after clearing. + + Returns: + None + + Example: + >>> builder = FunctionBuilder(client) + >>> func1 = builder.create_function_from_schema(schema, "server1") # Compiles and caches + >>> func2 = builder.create_function_from_schema(schema, "server1") # Uses cache + >>> + >>> builder.clear_cache() + >>> func3 = builder.create_function_from_schema(schema, "server1") # Recompiles + """ self._function_cache.clear() diff --git a/src/mcpd/mcpd_client.py b/src/mcpd/mcpd_client.py index c3ff75b..5e20e6e 100644 --- a/src/mcpd/mcpd_client.py +++ b/src/mcpd/mcpd_client.py @@ -3,57 +3,257 @@ import requests from .dynamic_caller import DynamicCaller -from .exceptions import McpdError +from .exceptions import ( + AuthenticationError, + ConnectionError, + McpdError, + ServerNotFoundError, + TimeoutError, + ToolExecutionError, +) from .function_builder import FunctionBuilder class McpdClient: - """Client for interacting with MCP servers through a proxy endpoint.""" + """ + Client for interacting with MCP (Model Context Protocol) servers through an mcpd daemon. + + The McpdClient provides a high-level interface to discover, inspect, and invoke tools + exposed by MCP servers running behind an mcpd daemon proxy/gateway. + + Attributes: + call: Dynamic interface for invoking tools using dot notation. + + Example: + >>> from mcpd import McpdClient + >>> + >>> # Initialize client + >>> client = McpdClient(api_endpoint="http://localhost:8090") + >>> + >>> # List available servers + >>> servers = client.servers() + >>> print(servers) # ['time', 'fetch', 'git'] + >>> + >>> # Invoke a tool dynamically + >>> result = client.call.time.get_current_time(timezone="UTC") + >>> print(result) # {'time': '2024-01-15T10:30:00Z'} + """ def __init__(self, api_endpoint: str, api_key: str | None = None): """ - Initialize the MCP client. + Initialize a new McpdClient instance. Args: - api_endpoint: The proxy endpoint URL for MCP servers - api_key: Optional API key for authentication + api_endpoint: The base URL of the mcpd daemon (e.g., "http://localhost:8090"). + Trailing slashes will be automatically removed. + api_key: Optional API key for Bearer token authentication. If provided, + will be included in all requests as "Authorization: Bearer {api_key}". + + Raises: + ValueError: If api_endpoint is empty or invalid. + + Example: + >>> # Basic initialization + >>> client = McpdClient(api_endpoint="http://localhost:8090") + >>> + >>> # With authentication + >>> client = McpdClient( + ... api_endpoint="https://mcpd.example.com", + ... api_key="your-api-key-here" # pragma: allowlist secret + ... ) """ - self.endpoint = api_endpoint.rstrip("/") - self.api_key = api_key - self.session = requests.Session() + self._endpoint = api_endpoint.rstrip("/").strip() + if self._endpoint == "": + raise ValueError("api_endpoint must be set") + self._api_key = api_key + self._session = requests.Session() # Initialize components self._function_builder = FunctionBuilder(self) # Set up authentication - if self.api_key: - self.session.headers.update({"Authorization": f"Bearer {self.api_key}"}) + if self._api_key: + self._session.headers.update({"Authorization": f"Bearer {self._api_key}"}) # Dynamic call interface self.call = DynamicCaller(self) def _perform_call(self, server_name: str, tool_name: str, params: dict[str, Any]) -> Any: - """Perform the actual API call to the MCP server.""" + """ + Perform the actual API call to execute a tool on an MCP server. + + This method handles the low-level HTTP communication with the mcpd daemon + and maps various failure modes to specific exception types. It is used + internally by both the dynamic caller interface and generated agent functions. + + Args: + server_name: The name of the MCP server hosting the tool. + tool_name: The name of the tool to execute. + params: Dictionary of parameters to pass to the tool. Should match + the tool's inputSchema requirements. + + Returns: + The tool's response, typically a dictionary containing the results. + The exact structure depends on the specific tool being called. + + Raises: + ConnectionError: If unable to connect to the mcpd daemon (daemon not + running, network issues, incorrect endpoint). + TimeoutError: If the tool execution takes longer than 30 seconds. + AuthenticationError: If the API key is invalid or missing (HTTP 401). + ServerNotFoundError: If the specified server doesn't exist (HTTP 404). + ToolExecutionError: If the tool execution fails on the server side + (HTTP 4xx/5xx errors, invalid parameters, server errors). + McpdError: For any other unexpected request failures. + + Note: + All raised exceptions use proper exception chaining (``raise ... from e``) + to preserve the original HTTP/network error details. The original + exception can be accessed via the ``__cause__`` attribute for debugging. + + Example: + This method is typically called indirectly through the dynamic interface: + + >>> # This call: + >>> client.call.time.get_current_time(timezone="UTC") + >>> # Eventually calls: + >>> client._perform_call("time", "get_current_time", {"timezone": "UTC"}) + """ try: - url = f"{self.endpoint}/api/v1/servers/{server_name}/tools/{tool_name}" - response = self.session.post(url, json=params, timeout=30) + url = f"{self._endpoint}/api/v1/servers/{server_name}/tools/{tool_name}" + response = self._session.post(url, json=params, timeout=30) response.raise_for_status() return response.json() + except requests.exceptions.ConnectionError as e: + raise ConnectionError(f"Cannot connect to mcpd daemon at {self._endpoint}: {e}") from e + except requests.exceptions.Timeout as e: + raise TimeoutError( + f"Tool execution timed out after 30 seconds", operation=f"{server_name}.{tool_name}", timeout=30 + ) from e + except requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + raise AuthenticationError( + f"Authentication failed when calling '{tool_name}' on '{server_name}': {e}" + ) from e + elif e.response.status_code == 404: + raise ServerNotFoundError(f"Server '{server_name}' not found", server_name=server_name) from e + elif e.response.status_code >= 500: + raise ToolExecutionError( + f"Server error when executing '{tool_name}' on '{server_name}': {e}", + server_name=server_name, + tool_name=tool_name, + ) from e + else: + raise ToolExecutionError( + f"Error calling tool '{tool_name}' on server '{server_name}': {e}", + server_name=server_name, + tool_name=tool_name, + ) from e except requests.exceptions.RequestException as e: raise McpdError(f"Error calling tool '{tool_name}' on server '{server_name}': {e}") from e def servers(self) -> list[str]: - """Get a list of all configured server names.""" + """ + Retrieve a list of all available MCP server names. + + Queries the mcpd daemon to discover all configured and running MCP servers. + Server names can be used with other methods to inspect tools or invoke them. + + Returns: + A list of server name strings. Empty list if no servers are configured. + + Raises: + ConnectionError: If unable to connect to the mcpd daemon. + TimeoutError: If the request times out after 5 seconds. + AuthenticationError: If the API key is invalid or missing. + McpdError: If the mcpd daemon returns an error or the API endpoint + is not available (check daemon version/configuration). + + Example: + >>> client = McpdClient(api_endpoint="http://localhost:8090") + >>> available_servers = client.servers() + >>> print(available_servers) + ['time', 'fetch', 'git', 'filesystem'] + >>> + >>> # Check if a specific server exists + >>> if 'git' in available_servers: + ... print("Git server is available!") + """ try: - url = f"{self.endpoint}/api/v1/servers" - response = self.session.get(url, timeout=5) + url = f"{self._endpoint}/api/v1/servers" + response = self._session.get(url, timeout=5) response.raise_for_status() return response.json() + except requests.exceptions.ConnectionError as e: + raise ConnectionError(f"Cannot connect to mcpd daemon at {self._endpoint}: {e}") from e + except requests.exceptions.Timeout as e: + raise TimeoutError(f"Request timed out after 5 seconds", operation="list servers", timeout=5) from e + except requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + raise AuthenticationError(f"Authentication failed: {e}") from e + elif e.response.status_code == 404: + raise McpdError( + f"Servers API endpoint not found - ensure mcpd daemon is running and supports API version v1: {e}" + ) from e + elif e.response.status_code >= 500: + raise McpdError(f"mcpd daemon server error: {e}") from e + else: + raise McpdError(f"Error listing servers: {e}") from e except requests.exceptions.RequestException as e: raise McpdError(f"Error listing servers: {e}") from e def tools(self, server_name: str | None = None) -> dict[str, list[dict]] | list[dict]: - """Get tool schema definitions.""" + """ + Retrieve tool schema definitions from one or all MCP servers. + + Tool schemas describe the available tools, their parameters, and expected types. + These schemas follow the JSON Schema specification and can be used to validate + inputs or generate UI forms. + + When server_name is provided, queries that specific server directly. When None, + first calls servers() to get all server names, then queries each server individually. + + Args: + server_name: Optional name of a specific server to query. If None, + retrieves tools from all available servers. + + Returns: + - If server_name is provided: A list of tool schema dictionaries for that server. + - If server_name is None: A dictionary mapping server names to their tool schemas. + + Each tool schema contains: + - 'name': The tool's identifier + - 'description': Human-readable description + - 'inputSchema': JSON Schema for the tool's parameters + + Raises: + 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 the specified server doesn't exist (when server_name provided). + McpdError: For other daemon errors or API issues. + + Examples: + >>> client = McpdClient(api_endpoint="http://localhost:8090") + >>> + >>> # Get tools from a specific server + >>> time_tools = client.tools("time") + >>> print(time_tools[0]['name']) + 'get_current_time' + >>> + >>> # Get all tools from all servers + >>> all_tools = client.tools() + >>> for server, tools in all_tools.items(): + ... print(f"{server}: {len(tools)} tools") + time: 2 tools + fetch: 1 tools + git: 5 tools + + >>> # Inspect a tool's schema + >>> tool_schema = time_tools[0] + >>> print(tool_schema['inputSchema']['properties']) + {'timezone': {'type': 'string', 'description': 'IANA timezone'}} + """ if server_name: return self._get_tool_definitions(server_name) @@ -70,16 +270,81 @@ def tools(self, server_name: str | None = None) -> dict[str, list[dict]] | list[ def _get_tool_definitions(self, server_name: str) -> list[dict[str, Any]]: """Get tool definitions for a specific server.""" try: - url = f"{self.endpoint}/api/v1/servers/{server_name}/tools" - response = self.session.get(url, timeout=5) + url = f"{self._endpoint}/api/v1/servers/{server_name}/tools" + response = self._session.get(url, timeout=5) response.raise_for_status() data = response.json() return data.get("tools", []) + except requests.exceptions.ConnectionError as e: + raise ConnectionError(f"Cannot connect to mcpd daemon at {self._endpoint}: {e}") from e + except requests.exceptions.Timeout as e: + raise TimeoutError( + f"Request timed out after 5 seconds", operation=f"list tools for {server_name}", timeout=5 + ) from e + except requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + raise AuthenticationError(f"Authentication failed when accessing server '{server_name}': {e}") from e + elif e.response.status_code == 404: + raise ServerNotFoundError(f"Server '{server_name}' not found", server_name=server_name) from e + else: + raise McpdError(f"Error listing tool definitions for server '{server_name}': {e}") from e except requests.exceptions.RequestException as e: - raise McpdError(f"Error listing tool definitions for server '{server_name}'") from e + raise McpdError(f"Error listing tool definitions for server '{server_name}': {e}") from e def agent_tools(self) -> list[Callable[..., Any]]: - """Get a list of callable functions suitable for agentic frameworks.""" + """ + 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. + + The generated functions are cached for performance. Use clear_agent_tools_cache() + to force regeneration if servers or tools have changed. + + Returns: + A list of callable functions, one for each tool across all servers. + Each function has the following attributes: + - __name__: The tool's qualified name (e.g., "time__get_current_time") + - __doc__: The tool's description + - _schema: The original JSON Schema + - _server_name: The server hosting this tool + - _tool_name: The tool's name + + Raises: + 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. + + Example: + >>> from any_agent import AnyAgent, AgentConfig + >>> from mcpd import McpdClient + >>> + >>> client = McpdClient(api_endpoint="http://localhost:8090") + >>> + >>> # Get all tools as callable functions + >>> tools = client.agent_tools() + >>> print(f"Generated {len(tools)} callable tools") + >>> + >>> # Use with an AI agent framework + >>> agent_config = AgentConfig( + ... tools=tools, + ... model_id="gpt-4", + ... instructions="Help the user with their tasks." + ... ) + >>> agent = AnyAgent.create("assistant", agent_config) + >>> + >>> # The agent can now call any MCP tool automatically + >>> response = agent.run("What time is it in Tokyo?") + + Note: + The generated functions capture the client instance and will use the + same authentication and endpoint configuration. They are thread-safe + but may not be suitable for pickling due to the embedded client state. + """ agent_tools = [] all_tools = self.tools() @@ -91,13 +356,73 @@ def agent_tools(self) -> list[Callable[..., Any]]: return agent_tools def has_tool(self, server_name: str, tool_name: str) -> bool: - """Check if a specific tool exists on a given server.""" + """ + Check if a specific tool exists on a given server. + + This method queries the server's tool definitions via tools(server_name) and + searches for the specified tool. It's useful for validation before attempting + to call a tool, especially when tool names are provided by user input or + external sources. + + Args: + server_name: The name of the MCP server to check. + tool_name: The name of the tool to look for. + + Returns: + True if the tool exists on the specified server, False otherwise. + Returns False if the server doesn't exist, is unreachable, or if any + other error occurs during the check. + + Example: + >>> client = McpdClient(api_endpoint="http://localhost:8090") + >>> + >>> # Check before calling + >>> if client.has_tool("time", "get_current_time"): + ... result = client.call.time.get_current_time(timezone="UTC") + ... else: + ... print("Tool not available") + >>> + >>> # Validate user input + >>> user_server = input("Enter server name: ") + >>> user_tool = input("Enter tool name: ") + >>> if not client.has_tool(user_server, user_tool): + ... print(f"Error: Tool '{user_tool}' not found on '{user_server}'") + """ try: tool_defs = self.tools(server_name=server_name) return any(tool.get("name") == tool_name for tool in tool_defs) except McpdError: return False - def clear_agent_tools_cache(self): - """Clear the cached compiled functions that act as agent tools.""" + def clear_agent_tools_cache(self) -> None: + """ + Clear the cache of generated callable functions from agent_tools(). + + This method clears the internal FunctionBuilder cache that stores compiled + function templates. Call this when server configurations have changed to + ensure agent_tools() regenerates functions with the latest definitions. + + Call this method when: + - MCP servers have been added or removed from the daemon + - Tool definitions have changed on existing servers + - You want to force regeneration of function wrappers + + This only affects the internal cache used by agent_tools(). It does not + affect the mcpd daemon or MCP servers themselves. + + Returns: + None + + Example: + >>> client = McpdClient(api_endpoint="http://localhost:8090") + >>> + >>> # Initial tool generation + >>> tools_v1 = client.agent_tools() + >>> + >>> # ... MCP server configuration changes ... + >>> + >>> # Clear cache to get updated tools + >>> client.clear_agent_tools_cache() + >>> tools_v2 = client.agent_tools() # Regenerates from latest definitions + """ self._function_builder.clear_cache() diff --git a/tests/unit/test_dynamic_caller.py b/tests/unit/test_dynamic_caller.py index 0d73f49..6411aa7 100644 --- a/tests/unit/test_dynamic_caller.py +++ b/tests/unit/test_dynamic_caller.py @@ -43,9 +43,9 @@ def test_init(self, mock_client): def test_getattr_tool_exists(self, server_proxy, mock_client): mock_client.has_tool.return_value = True - tool_callable = server_proxy.test_tool + tool_function = server_proxy.test_tool - assert callable(tool_callable) + assert callable(tool_function) mock_client.has_tool.assert_called_once_with("test_server", "test_tool") def test_getattr_tool_not_exists(self, server_proxy, mock_client): @@ -54,31 +54,31 @@ def test_getattr_tool_not_exists(self, server_proxy, mock_client): with pytest.raises(McpdError, match="Tool 'nonexistent_tool' not found on server 'test_server'"): server_proxy.nonexistent_tool - def test_tool_callable_execution(self, server_proxy, mock_client): + def test_tool_function_execution(self, server_proxy, mock_client): mock_client.has_tool.return_value = True - tool_callable = server_proxy.test_tool - result = tool_callable(param1="value1", param2="value2") + tool_function = server_proxy.test_tool + result = tool_function(param1="value1", param2="value2") assert result == {"result": "success"} mock_client._perform_call.assert_called_once_with( "test_server", "test_tool", {"param1": "value1", "param2": "value2"} ) - def test_tool_callable_no_params(self, server_proxy, mock_client): + def test_tool_function_no_params(self, server_proxy, mock_client): mock_client.has_tool.return_value = True - tool_callable = server_proxy.test_tool - result = tool_callable() + tool_function = server_proxy.test_tool + result = tool_function() assert result == {"result": "success"} mock_client._perform_call.assert_called_once_with("test_server", "test_tool", {}) - def test_tool_callable_with_kwargs(self, server_proxy, mock_client): + def test_tool_function_with_kwargs(self, server_proxy, mock_client): mock_client.has_tool.return_value = True - tool_callable = server_proxy.test_tool - result = tool_callable( + tool_function = server_proxy.test_tool + result = tool_function( string_param="test", int_param=42, bool_param=True, list_param=["a", "b", "c"], dict_param={"key": "value"} ) @@ -124,10 +124,10 @@ def test_error_propagation(self, server_proxy, mock_client): mock_client.has_tool.return_value = True mock_client._perform_call.side_effect = Exception("API Error") - tool_callable = server_proxy.test_tool + tool_function = server_proxy.test_tool with pytest.raises(Exception, match="API Error"): - tool_callable(param="value") + tool_function(param="value") class TestIntegration: diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index 44680f7..c05ad44 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -1,6 +1,19 @@ +from unittest.mock import Mock, patch + import pytest +import requests -from mcpd.exceptions import McpdError +from mcpd import McpdClient +from mcpd.exceptions import ( + AuthenticationError, + ConnectionError, + McpdError, + ServerNotFoundError, + TimeoutError, + ToolExecutionError, + ToolNotFoundError, + ValidationError, +) class TestMcpdError: @@ -103,3 +116,273 @@ def test_mcpd_error_with_complex_message(self): assert "Complex error with data:" in str(error) assert "test" in str(error) assert "example" in str(error) + + +class TestExceptionHierarchy: + """Test that all exceptions inherit from McpdError.""" + + def test_all_exceptions_inherit_from_mcpd_error(self): + """Verify exception hierarchy.""" + assert issubclass(ConnectionError, McpdError) + assert issubclass(AuthenticationError, McpdError) + assert issubclass(ServerNotFoundError, McpdError) + assert issubclass(ToolNotFoundError, McpdError) + assert issubclass(ToolExecutionError, McpdError) + assert issubclass(TimeoutError, McpdError) + assert issubclass(ValidationError, McpdError) + + def test_backward_compatibility(self): + """Test that catching McpdError still works for all subclasses.""" + exceptions = [ + ConnectionError("test"), + AuthenticationError("test"), + ServerNotFoundError("test", server_name="server1"), + ToolNotFoundError("test", server_name="server1", tool_name="tool1"), + ToolExecutionError("test"), + TimeoutError("test"), + ValidationError("test"), + ] + + for exc in exceptions: + try: + raise exc + except McpdError: + pass # Should catch all subclasses + except Exception: + pytest.fail(f"{exc.__class__.__name__} not caught by McpdError") + + +class TestConnectionError: + """Test ConnectionError is raised appropriately.""" + + @patch("requests.Session") + def test_connection_error_on_servers(self, mock_session_class): + """Test ConnectionError when cannot connect to daemon.""" + mock_session = Mock() + mock_session_class.return_value = mock_session + + # Simulate connection error + mock_session.get.side_effect = requests.exceptions.ConnectionError("Connection refused") + + client = McpdClient(api_endpoint="http://localhost:8090") + + with pytest.raises(ConnectionError) as exc_info: + client.servers() + + assert "Cannot connect to mcpd daemon" in str(exc_info.value) + assert "localhost:8090" in str(exc_info.value) + + @patch("requests.Session") + def test_connection_error_on_tool_call(self, mock_session_class): + """Test ConnectionError when calling a tool.""" + mock_session = Mock() + mock_session_class.return_value = mock_session + + mock_session.post.side_effect = requests.exceptions.ConnectionError("Connection refused") + + client = McpdClient(api_endpoint="http://localhost:8090") + + with pytest.raises(ConnectionError) as exc_info: + client._perform_call("test_server", "test_tool", {}) + + assert "Cannot connect to mcpd daemon" in str(exc_info.value) + + +class TestAuthenticationError: + """Test AuthenticationError is raised appropriately.""" + + @patch("requests.Session") + def test_authentication_error_401(self, mock_session_class): + """Test AuthenticationError on 401 response.""" + mock_session = Mock() + mock_session_class.return_value = mock_session + + # Simulate 401 error + mock_response = Mock() + mock_response.status_code = 401 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(response=mock_response) + mock_session.get.return_value = mock_response + + client = McpdClient(api_endpoint="http://localhost:8090", api_key="bad-key") # pragma: allowlist secret + + with pytest.raises(AuthenticationError) as exc_info: + client.servers() + + assert "Authentication failed" in str(exc_info.value) + + +class TestServerNotFoundError: + """Test ServerNotFoundError is raised appropriately.""" + + @patch("requests.Session") + def test_server_not_found_404(self, mock_session_class): + """Test ServerNotFoundError on 404 when getting tools.""" + mock_session = Mock() + mock_session_class.return_value = mock_session + + # Simulate 404 error + mock_response = Mock() + mock_response.status_code = 404 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(response=mock_response) + mock_session.get.return_value = mock_response + + client = McpdClient(api_endpoint="http://localhost:8090") + + with pytest.raises(ServerNotFoundError) as exc_info: + client._get_tool_definitions("nonexistent_server") + + assert "Server 'nonexistent_server' not found" in str(exc_info.value) + assert exc_info.value.server_name == "nonexistent_server" + + def test_server_not_found_attributes(self): + """Test ServerNotFoundError attributes.""" + exc = ServerNotFoundError("Server not found", server_name="test_server") + assert exc.server_name == "test_server" + + +class TestToolNotFoundError: + """Test ToolNotFoundError is raised appropriately.""" + + @patch("requests.Session") + def test_tool_not_found_in_dynamic_caller(self, mock_session_class): + """Test ToolNotFoundError when tool doesn't exist.""" + mock_session = Mock() + mock_session_class.return_value = mock_session + + # Mock successful server list with existing tool + mock_response = Mock() + mock_response.json.return_value = {"tools": [{"name": "existing_tool"}]} + mock_response.raise_for_status.return_value = None + mock_session.get.return_value = mock_response + + client = McpdClient(api_endpoint="http://localhost:8090") + + with pytest.raises(ToolNotFoundError) as exc_info: + # This will trigger the has_tool check in dynamic_caller + client.call.test_server.nonexistent_tool() + + assert "Tool 'nonexistent_tool' not found on server 'test_server'" in str(exc_info.value) + assert exc_info.value.server_name == "test_server" + assert exc_info.value.tool_name == "nonexistent_tool" + + def test_tool_not_found_attributes(self): + """Test ToolNotFoundError attributes.""" + exc = ToolNotFoundError("Tool not found", server_name="test_server", tool_name="test_tool") + assert exc.server_name == "test_server" + assert exc.tool_name == "test_tool" + + +class TestToolExecutionError: + """Test ToolExecutionError is raised appropriately.""" + + @patch("requests.Session") + def test_tool_execution_error_500(self, mock_session_class): + """Test ToolExecutionError on 500 server error.""" + mock_session = Mock() + mock_session_class.return_value = mock_session + + # Simulate 500 error + mock_response = Mock() + mock_response.status_code = 500 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(response=mock_response) + mock_session.post.return_value = mock_response + + client = McpdClient(api_endpoint="http://localhost:8090") + + with pytest.raises(ToolExecutionError) as exc_info: + client._perform_call("test_server", "test_tool", {"param": "value"}) + + assert "Server error when executing 'test_tool'" in str(exc_info.value) + assert exc_info.value.server_name == "test_server" + assert exc_info.value.tool_name == "test_tool" + + @patch("requests.Session") + def test_tool_execution_error_400(self, mock_session_class): + """Test ToolExecutionError on 400 bad request.""" + mock_session = Mock() + mock_session_class.return_value = mock_session + + # Simulate 400 error + mock_response = Mock() + mock_response.status_code = 400 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(response=mock_response) + mock_session.post.return_value = mock_response + + client = McpdClient(api_endpoint="http://localhost:8090") + + with pytest.raises(ToolExecutionError) as exc_info: + client._perform_call("test_server", "test_tool", {"bad": "param"}) + + assert "Error calling tool 'test_tool'" in str(exc_info.value) + assert exc_info.value.server_name == "test_server" + assert exc_info.value.tool_name == "test_tool" + + def test_tool_execution_error_attributes(self): + """Test ToolExecutionError attributes.""" + details = {"error_code": "INVALID_PARAMS"} + exc = ToolExecutionError("Execution failed", server_name="test_server", tool_name="test_tool", details=details) + assert exc.server_name == "test_server" + assert exc.tool_name == "test_tool" + assert exc.details == details + + +class TestTimeoutError: + """Test TimeoutError is raised appropriately.""" + + @patch("requests.Session") + def test_timeout_error_on_servers(self, mock_session_class): + """Test TimeoutError when request times out.""" + mock_session = Mock() + mock_session_class.return_value = mock_session + + # Simulate timeout + mock_session.get.side_effect = requests.exceptions.Timeout("Request timed out") + + client = McpdClient(api_endpoint="http://localhost:8090") + + with pytest.raises(TimeoutError) as exc_info: + client.servers() + + assert "Request timed out after 5 seconds" in str(exc_info.value) + assert exc_info.value.operation == "list servers" + assert exc_info.value.timeout == 5 + + @patch("requests.Session") + def test_timeout_error_on_tool_call(self, mock_session_class): + """Test TimeoutError when tool execution times out.""" + mock_session = Mock() + mock_session_class.return_value = mock_session + + mock_session.post.side_effect = requests.exceptions.Timeout("Request timed out") + + client = McpdClient(api_endpoint="http://localhost:8090") + + with pytest.raises(TimeoutError) as exc_info: + client._perform_call("slow_server", "slow_tool", {}) + + assert "Tool execution timed out after 30 seconds" in str(exc_info.value) + assert exc_info.value.operation == "slow_server.slow_tool" + assert exc_info.value.timeout == 30 + + def test_timeout_error_attributes(self): + """Test TimeoutError attributes.""" + exc = TimeoutError("Operation timed out", operation="fetch_data", timeout=30.0) + assert exc.operation == "fetch_data" + assert exc.timeout == 30.0 + + +class TestValidationError: + """Test ValidationError is raised appropriately.""" + + def test_validation_error_attributes(self): + """Test ValidationError stores validation errors.""" + errors = ["Missing field 'name'", "Invalid type for 'age'"] + exc = ValidationError("Validation failed", validation_errors=errors) + + assert exc.validation_errors == errors + assert "Validation failed" in str(exc) + + def test_validation_error_empty_list(self): + """Test ValidationError with no specific errors.""" + exc = ValidationError("Validation failed") + assert exc.validation_errors == [] diff --git a/tests/unit/test_function_builder.py b/tests/unit/test_function_builder.py index d099d1c..ab19239 100644 --- a/tests/unit/test_function_builder.py +++ b/tests/unit/test_function_builder.py @@ -70,7 +70,7 @@ def test_create_function_from_schema_execution(self, function_builder): result = func(param1="test_value") assert result == {"result": "success"} - function_builder.client._perform_call.assert_called_once_with( + function_builder._client._perform_call.assert_called_once_with( "test_server", "test_tool", {"param1": "test_value"} ) @@ -108,11 +108,11 @@ def test_create_function_from_schema_optional_params(self, function_builder): # Test with only required param result = func(param1="test") - function_builder.client._perform_call.assert_called_with("test_server", "test_tool", {"param1": "test"}) + function_builder._client._perform_call.assert_called_with("test_server", "test_tool", {"param1": "test"}) # Test with optional param result = func(param1="test", param2="optional") - function_builder.client._perform_call.assert_called_with( + function_builder._client._perform_call.assert_called_with( "test_server", "test_tool", {"param1": "test", "param2": "optional"} ) @@ -126,7 +126,7 @@ def test_create_function_from_schema_no_params(self, function_builder): func = function_builder.create_function_from_schema(schema, "test_server") result = func() - function_builder.client._perform_call.assert_called_once_with("test_server", "test_tool", {}) + function_builder._client._perform_call.assert_called_once_with("test_server", "test_tool", {}) def test_create_function_from_schema_caching(self, function_builder): schema = { @@ -200,7 +200,7 @@ def test_create_namespace(self, function_builder): assert "McpdError" in namespace assert "client" in namespace - assert namespace["client"] is function_builder.client + assert namespace["client"] is function_builder._client assert "Any" in namespace assert "str" in namespace assert "int" in namespace diff --git a/tests/unit/test_mcpd_client.py b/tests/unit/test_mcpd_client.py index 78962e2..0d2f296 100644 --- a/tests/unit/test_mcpd_client.py +++ b/tests/unit/test_mcpd_client.py @@ -10,21 +10,21 @@ class TestMcpdClient: def test_init_basic(self): client = McpdClient(api_endpoint="http://localhost:9999") - assert client.endpoint == "http://localhost:9999" - assert client.api_key is None - assert hasattr(client, "session") + assert client._endpoint == "http://localhost:9999" + assert client._api_key is None + assert hasattr(client, "_session") assert hasattr(client, "call") def test_init_with_auth(self): client = McpdClient(api_endpoint="http://localhost:9090", api_key="test-key123") - assert client.endpoint == "http://localhost:9090" - assert client.api_key == "test-key123" # pragma: allowlist secret - assert "Authorization" in client.session.headers - assert client.session.headers["Authorization"] == "Bearer test-key123" + assert client._endpoint == "http://localhost:9090" + assert client._api_key == "test-key123" # pragma: allowlist secret + assert "Authorization" in client._session.headers + assert client._session.headers["Authorization"] == "Bearer test-key123" # pragma: allowlist secret def test_init_strips_trailing_slash(self): client = McpdClient("http://localhost:8090/") - assert client.endpoint == "http://localhost:8090" + assert client._endpoint == "http://localhost:8090" @patch.object(Session, "get") def test_servers_success(self, mock_get, client, api_url):