diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1fd5cf3 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: ensure-scripts-exec setup test + +ensure-scripts-exec: + @chmod +x scripts/* || true + +setup: ensure-scripts-exec + @scripts/setup_uv.sh + +test: + @uv run $(DEBUGPY_ARGS) -m pytest tests diff --git a/README.md b/README.md index fa235e5..b4b90d8 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,39 @@ dependencies = [ Then run: ```bash +uv venv +source .venv/bin/activate uv sync ``` +## Dev Setup + +Use the `Makefile` target to ensure `uv` is installed, and your virtual environment is active and sync'd. + +```bash +make setup +``` + +## Testing + +Ensure you have the correct dependencies installed for testing: + +```bash +uv sync --group tests +``` + +Then to run all tests: + +```bash +uv run pytest tests +``` + +... or via `Makefile`: + +```bash +make test +``` + ## Quick Start ```python diff --git a/examples/readme_maker/readme_maker.py b/examples/readme_maker/readme_maker.py index 540ab0d..db2b5e1 100644 --- a/examples/readme_maker/readme_maker.py +++ b/examples/readme_maker/readme_maker.py @@ -30,8 +30,8 @@ If a template README file from an original repository ({input_repo_readme}) is provided: - Template Utilization: Use the structure and style of the original README file as a guide. -- Content Adaptation: Modify the content to accurately describe the target repository. -This step requires analyzing the entire codebase of the target repository ({target_repo}) +- Content Adaptation: Modify the content to accurately describe the target repository. +This step requires analyzing the entire codebase of the target repository ({target_repo}) to understand its functionality, features, and any other relevant details that should be included in the README. If no template README file is provided: @@ -45,7 +45,7 @@ -License information -Acknowledgements (if applicable) -To create the README, analyze the entire codebase of the target repository ({target_repo}) +To create the README, analyze the entire codebase of the target repository ({target_repo}) to understand its functionality, features, and any other relevant details. After creating the new README file: @@ -55,8 +55,8 @@ commit message that clearly indicates the addition of the new README. 3. Pull Request (PR) Creation: Open a Pull Request against the main branch of the target repository, including the new README file. -4. PR Description: In the Pull Request description, mention that the README was generated -by an AI agent of the model {agent_model}. If a template was used, include a link to +4. PR Description: In the Pull Request description, mention that the README was generated +by an AI agent of the model {agent_model}. If a template was used, include a link to the original README template ({input_repo_readme}) used for generating the new README. When providing code snippets for this task, follow the guidelines for code modifications: diff --git a/pyproject.toml b/pyproject.toml index f9bc507..d119fe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcpd-sdk" -version = "0.0.1" +dynamic = ["version"] description = "mcpd Python SDK" readme = "README.md" license = {text = "Apache-2.0"} @@ -23,6 +23,8 @@ lint = [ tests = [ "pytest>=8.3.5", + "setuptools>=80.9.0", + "setuptools-scm[toml]>=8.3.1", ] all = [ @@ -31,6 +33,18 @@ all = [ { include-group = "tests" }, ] +[build-system] +requires = ["setuptools>=48", "setuptools_scm[toml]>=6.3.1"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +exclude = ["tests", "tests.*"] +where = ["src"] +namespaces = false + +[tool.setuptools.package-data] +"*" = ["py.typed"] + [tool.ruff] fix = true line-length=120 diff --git a/scripts/setup_uv.sh b/scripts/setup_uv.sh new file mode 100755 index 0000000..05b7c5e --- /dev/null +++ b/scripts/setup_uv.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +# ########## +# Install UV +# ########## + +# Expected installation path for uv +# See: https://docs.astral.sh/uv/configuration/installer/#changing-the-install-path +LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}" + +# Ensure LOCAL_BIN exists and is on PATH +mkdir -p "$LOCAL_BIN" +if [[ ":$PATH:" != *":$LOCAL_BIN:"* ]]; then + export PATH="$LOCAL_BIN:$PATH" +fi + +VENV_DIR=".venv" + +# Install or update uv +if ! command -v uv &>/dev/null; then + echo "uv not found – installing to $LOCAL_BIN" + curl -fsSL https://astral.sh/uv/install.sh | UV_INSTALL_DIR="$LOCAL_BIN" sh +else + current=$(uv --version | awk '{print $2}') + echo "Found uv v$current" + if command -v jq &>/dev/null; then + latest=$(curl -fsS https://api.github.com/repos/astral-sh/uv/releases/latest \ + | jq -r .tag_name) + if [[ "$current" != "$latest" ]]; then + echo "Updating uv: $current → $latest" + uv self update + fi + fi +fi + +# Bootstrap root .venv +echo "Bootstrapping root .venv in folder $VENV_DIR" +uv venv "$VENV_DIR" +uv sync --group all --active + +echo "Done! Root environment is ready in: $VENV_DIR" + +# After detecting PATH lacked LOCAL_BIN… +if [[ ":$PATH:" != *":$LOCAL_BIN:"* ]]; then + echo "Note: added $LOCAL_BIN to PATH for this session." + echo "To make it permanent, add to your shell profile:" + echo " export PATH=\"$LOCAL_BIN:\$PATH\"" +fi diff --git a/src/mcpd_sdk/__init__.py b/src/mcpd_sdk/__init__.py index ace6348..c94f8c9 100644 --- a/src/mcpd_sdk/__init__.py +++ b/src/mcpd_sdk/__init__.py @@ -1 +1,16 @@ +from importlib.metadata import PackageNotFoundError, version + from .mcpd_client import McpdClient, McpdError + +try: + __version__ = version("mcpd_sdk") +except PackageNotFoundError: + # In the case of local development + # i.e., running directly from the source directory without package being installed + __version__ = "0.0.0-dev" + +__all__ = [ + "McpdClient", + "McpdError", + "__version__", +] diff --git a/src/mcpd_sdk/py.typed b/src/mcpd_sdk/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..40992d3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,57 @@ +from unittest.mock import Mock + +import pytest + +from mcpd_sdk import McpdClient, McpdError + + +@pytest.fixture(scope="function") +def mock_client(): + client = Mock() + client._perform_call = Mock(return_value={"result": "success"}) + client.has_tool = Mock(return_value=True) + return client + + +@pytest.fixture(scope="function") +def mock_response(): + response = Mock() + response.json.return_value = {"result": "success"} + response.raise_for_status.return_value = None + return response + + +@pytest.fixture(scope="session") +def basic_schema(): + return { + "name": "test_tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "First parameter"}, + "param2": {"type": "integer", "description": "Second parameter"}, + }, + "required": ["param1"], + }, + } + + +@pytest.fixture(scope="session") +def fqdn() -> str: + return "http://localhost:8090" + + +@pytest.fixture(scope="session") +def api_url(fqdn) -> str: + return fqdn + "/api/v1" + + +@pytest.fixture(scope="function") +def client(fqdn): + return McpdClient(api_endpoint=fqdn) + + +@pytest.fixture(scope="function") +def client_with_auth(fqdn): + return McpdClient(api_endpoint=fqdn, api_key="test-key") # pragma: allowlist secret diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_dynamic_caller.py b/tests/unit/test_dynamic_caller.py new file mode 100644 index 0000000..d40714d --- /dev/null +++ b/tests/unit/test_dynamic_caller.py @@ -0,0 +1,175 @@ +import pytest + +from mcpd_sdk import McpdError +from mcpd_sdk.dynamic_caller import DynamicCaller, ServerProxy + + +class TestDynamicCaller: + @pytest.fixture + def dynamic_caller(self, mock_client): + return DynamicCaller(mock_client) + + def test_init(self, mock_client): + caller = DynamicCaller(mock_client) + assert caller._client is mock_client + + def test_getattr_returns_server_proxy(self, dynamic_caller, mock_client): + server_proxy = dynamic_caller.test_server + + assert isinstance(server_proxy, ServerProxy) + assert server_proxy._client is mock_client + assert server_proxy._server_name == "test_server" + + def test_multiple_server_access(self, dynamic_caller, mock_client): + server1 = dynamic_caller.server1 + server2 = dynamic_caller.server2 + + assert server1._server_name == "server1" + assert server2._server_name == "server2" + assert server1._client is mock_client + assert server2._client is mock_client + + +class TestServerProxy: + @pytest.fixture + def server_proxy(self, mock_client): + return ServerProxy(mock_client, "test_server") + + def test_init(self, mock_client): + proxy = ServerProxy(mock_client, "test_server") + assert proxy._client is mock_client + assert proxy._server_name == "test_server" + + def test_getattr_tool_exists(self, server_proxy, mock_client): + mock_client.has_tool.return_value = True + + tool_callable = server_proxy.test_tool + + assert callable(tool_callable) + mock_client.has_tool.assert_called_once_with("test_server", "test_tool") + + def test_getattr_tool_not_exists(self, server_proxy, mock_client): + mock_client.has_tool.return_value = False + + 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): + mock_client.has_tool.return_value = True + + tool_callable = server_proxy.test_tool + result = tool_callable(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): + mock_client.has_tool.return_value = True + + tool_callable = server_proxy.test_tool + result = tool_callable() + + 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): + mock_client.has_tool.return_value = True + + tool_callable = server_proxy.test_tool + result = tool_callable( + string_param="test", int_param=42, bool_param=True, list_param=["a", "b", "c"], dict_param={"key": "value"} + ) + + expected_params = { + "string_param": "test", + "int_param": 42, + "bool_param": True, + "list_param": ["a", "b", "c"], + "dict_param": {"key": "value"}, + } + + assert result == {"result": "success"} + mock_client._perform_call.assert_called_once_with("test_server", "test_tool", expected_params) + + def test_multiple_tool_calls(self, server_proxy, mock_client): + mock_client.has_tool.return_value = True + + tool1 = server_proxy.tool1 + tool2 = server_proxy.tool2 + + result1 = tool1(param="value1") + result2 = tool2(param="value2") + + assert result1 == {"result": "success"} + assert result2 == {"result": "success"} + + assert mock_client._perform_call.call_count == 2 + mock_client._perform_call.assert_any_call("test_server", "tool1", {"param": "value1"}) + mock_client._perform_call.assert_any_call("test_server", "tool2", {"param": "value2"}) + + def test_has_tool_called_for_each_access(self, server_proxy, mock_client): + mock_client.has_tool.return_value = True + + # Access the same tool multiple times + tool1 = server_proxy.test_tool + tool2 = server_proxy.test_tool + + # has_tool should be called each time + assert mock_client.has_tool.call_count == 2 + mock_client.has_tool.assert_any_call("test_server", "test_tool") + + 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 + + with pytest.raises(Exception, match="API Error"): + tool_callable(param="value") + + +class TestIntegration: + def test_full_call_chain(self, mock_client): + # Test the complete client.call.server.tool() chain + caller = DynamicCaller(mock_client) + + result = caller.test_server.test_tool(param1="value1", param2="value2") + + assert result == {"result": "success"} + mock_client.has_tool.assert_called_once_with("test_server", "test_tool") + mock_client._perform_call.assert_called_once_with( + "test_server", "test_tool", {"param1": "value1", "param2": "value2"} + ) + + def test_multiple_servers_and_tools(self, mock_client): + caller = DynamicCaller(mock_client) + + # Call different tools on different servers + result1 = caller.server1.tool1(param="value1") + result2 = caller.server2.tool2(param="value2") + result3 = caller.server1.tool3(param="value3") + + assert result1 == {"result": "success"} + assert result2 == {"result": "success"} + assert result3 == {"result": "success"} + + # Verify all calls were made correctly + expected_calls = [(("server1", "tool1"), {}), (("server2", "tool2"), {}), (("server1", "tool3"), {})] + + has_tool_calls = mock_client.has_tool.call_args_list + perform_call_calls = mock_client._perform_call.call_args_list + + assert len(has_tool_calls) == 3 + assert len(perform_call_calls) == 3 + + # Check that has_tool was called with correct arguments + assert has_tool_calls[0][0] == ("server1", "tool1") + assert has_tool_calls[1][0] == ("server2", "tool2") + assert has_tool_calls[2][0] == ("server1", "tool3") + + # Check that _perform_call was called with correct arguments + assert perform_call_calls[0][0] == ("server1", "tool1", {"param": "value1"}) + assert perform_call_calls[1][0] == ("server2", "tool2", {"param": "value2"}) + assert perform_call_calls[2][0] == ("server1", "tool3", {"param": "value3"}) diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 0000000..14caade --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,105 @@ +import pytest + +from mcpd_sdk.exceptions import McpdError + + +class TestMcpdError: + def test_mcpd_error_inheritance(self): + """Test that McpdError inherits from Exception.""" + assert issubclass(McpdError, Exception) + + def test_mcpd_error_basic_creation(self): + """Test basic McpdError creation.""" + error = McpdError("Test error message") + assert str(error) == "Test error message" + + def test_mcpd_error_empty_message(self): + """Test McpdError with empty message.""" + error = McpdError("") + assert str(error) == "" + + def test_mcpd_error_none_message(self): + """Test McpdError with None message.""" + error = McpdError(None) + assert str(error) == "None" + + def test_mcpd_error_with_args(self): + """Test McpdError with multiple arguments.""" + error = McpdError("Error", "with", "multiple", "args") + assert "Error" in str(error) + + def test_mcpd_error_raising(self): + """Test that McpdError can be raised and caught.""" + with pytest.raises(McpdError): + raise McpdError("Test error") + + def test_mcpd_error_catching_as_exception(self): + """Test that McpdError can be caught as Exception.""" + with pytest.raises(Exception): + raise McpdError("Test error") + + def test_mcpd_error_chaining(self): + """Test error chaining with McpdError.""" + original_error = ValueError("Original error") + + try: + raise original_error + except ValueError as e: + chained_error = McpdError("Chained error") + chained_error.__cause__ = e + + assert chained_error.__cause__ is original_error + assert str(chained_error) == "Chained error" + + def test_mcpd_error_with_format_string(self): + """Test McpdError with format string.""" + server_name = "test_server" + tool_name = "test_tool" + error = McpdError(f"Error calling tool '{tool_name}' on server '{server_name}'") + + expected_message = "Error calling tool 'test_tool' on server 'test_server'" + assert str(error) == expected_message + + def test_mcpd_error_attributes(self): + """Test that McpdError has expected attributes.""" + error = McpdError("Test error") + + # Should have standard Exception attributes + assert hasattr(error, "args") + assert error.args == ("Test error",) + + def test_mcpd_error_repr(self): + """Test string representation of McpdError.""" + error = McpdError("Test error") + repr_str = repr(error) + + assert "McpdError" in repr_str + assert "Test error" in repr_str + + def test_mcpd_error_instance_check(self): + """Test isinstance checks with McpdError.""" + error = McpdError("Test error") + + assert isinstance(error, McpdError) + assert isinstance(error, Exception) + assert isinstance(error, BaseException) + + def test_mcpd_error_equality(self): + """Test equality comparison of McpdError instances.""" + error1 = McpdError("Same message") + error2 = McpdError("Same message") + error3 = McpdError("Different message") + + # Note: Exception instances are not equal even with same message + # This is standard Python behavior + assert error1 is not error2 + assert error1 is not error3 + + def test_mcpd_error_with_complex_message(self): + """Test McpdError with complex message containing various data types.""" + data = {"server": "test", "tool": "example", "params": [1, 2, 3]} + error = McpdError(f"Complex error with data: {data}") + + assert "Complex error with data:" in str(error) + assert "test" in str(error) + assert "example" in str(error) diff --git a/tests/unit/test_function_builder.py b/tests/unit/test_function_builder.py new file mode 100644 index 0000000..d4157a9 --- /dev/null +++ b/tests/unit/test_function_builder.py @@ -0,0 +1,247 @@ +from types import FunctionType + +import pytest + +from mcpd_sdk.exceptions import McpdError +from mcpd_sdk.function_builder import FunctionBuilder + + +class TestFunctionBuilder: + @pytest.fixture + def function_builder(self, mock_client): + return FunctionBuilder(mock_client) + + def test_safe_name_alphanumeric(self, function_builder): + assert function_builder._safe_name("test_func") == "test_func" + assert function_builder._safe_name("TestFunc123") == "TestFunc123" + + def test_safe_name_special_chars(self, function_builder): + assert function_builder._safe_name("test-func") == "test_func" + assert function_builder._safe_name("test.func") == "test_func" + assert function_builder._safe_name("test func") == "test_func" + assert function_builder._safe_name("test@func#") == "test_func_" + + def test_safe_name_leading_digit(self, function_builder): + assert function_builder._safe_name("123test") == "_123test" + assert function_builder._safe_name("9abc") == "_9abc" + + def test_function_name(self, function_builder): + result = function_builder._function_name("test_server", "test_tool") + assert result == "test_server__test_tool" + + def test_function_name_with_special_chars(self, function_builder): + result = function_builder._function_name("test-server", "test.tool") + assert result == "test_server__test_tool" + + def test_create_function_from_schema_basic(self, function_builder): + schema = { + "name": "test_tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "First parameter"}, + "param2": {"type": "integer", "description": "Second parameter"}, + }, + "required": ["param1"], + }, + } + + func = function_builder.create_function_from_schema(schema, "test_server") + + assert isinstance(func, FunctionType) + assert func.__name__ == "test_server__test_tool" + assert "A test tool" in func.__doc__ + assert "param1" in func.__doc__ + assert "param2" in func.__doc__ + + def test_create_function_from_schema_execution(self, function_builder): + schema = { + "name": "test_tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": {"param1": {"type": "string", "description": "First parameter"}}, + "required": ["param1"], + }, + } + + func = function_builder.create_function_from_schema(schema, "test_server") + result = func(param1="test_value") + + assert result == {"result": "success"} + function_builder.client._perform_call.assert_called_once_with( + "test_server", "test_tool", {"param1": "test_value"} + ) + + def test_create_function_from_schema_missing_required(self, function_builder): + schema = { + "name": "test_tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": {"param1": {"type": "string", "description": "First parameter"}}, + "required": ["param1"], + }, + } + + func = function_builder.create_function_from_schema(schema, "test_server") + + with pytest.raises(McpdError, match="Missing required parameters"): + func(param1=None) + + def test_create_function_from_schema_optional_params(self, function_builder): + schema = { + "name": "test_tool", + "description": "A test tool", + "inputSchema": { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "Required parameter"}, + "param2": {"type": "string", "description": "Optional parameter"}, + }, + "required": ["param1"], + }, + } + + func = function_builder.create_function_from_schema(schema, "test_server") + + # Test with only required param + result = func(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( + "test_server", "test_tool", {"param1": "test", "param2": "optional"} + ) + + def test_create_function_from_schema_no_params(self, function_builder): + schema = { + "name": "test_tool", + "description": "A test tool with no parameters", + "inputSchema": {"type": "object", "properties": {}, "required": []}, + } + + 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", {}) + + def test_create_function_from_schema_caching(self, function_builder): + schema = { + "name": "test_tool", + "description": "A test tool", + "inputSchema": {"type": "object", "properties": {"param1": {"type": "string"}}, "required": ["param1"]}, + } + + func1 = function_builder.create_function_from_schema(schema, "test_server") + func2 = function_builder.create_function_from_schema(schema, "test_server") + + # Should be different instances but same functionality + assert func1 is not func2 + assert func1.__name__ == func2.__name__ + + def test_create_annotations_basic_types(self, function_builder): + schema = { + "inputSchema": { + "type": "object", + "properties": { + "str_param": {"type": "string"}, + "int_param": {"type": "integer"}, + "bool_param": {"type": "boolean"}, + }, + "required": ["str_param"], + } + } + + annotations = function_builder._create_annotations(schema) + + assert annotations["str_param"] == str + assert annotations["int_param"] == (int | None) + assert annotations["bool_param"] == (bool | None) + + def test_create_docstring_with_params(self, function_builder): + schema = { + "description": "Test function description", + "inputSchema": { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "First parameter"}, + "param2": {"type": "integer", "description": "Second parameter"}, + }, + "required": ["param1"], + }, + } + + docstring = function_builder._create_docstring(schema) + + assert "Test function description" in docstring + assert "Args:" in docstring + assert "param1: First parameter" in docstring + assert "param2: Second parameter (optional)" in docstring + assert "Returns:" in docstring + assert "Raises:" in docstring + + def test_create_docstring_no_params(self, function_builder): + schema = { + "description": "Test function description", + "inputSchema": {"type": "object", "properties": {}, "required": []}, + } + + docstring = function_builder._create_docstring(schema) + + assert "Test function description" in docstring + assert "Args:" not in docstring + assert "Returns:" in docstring + + def test_create_namespace(self, function_builder): + namespace = function_builder._create_namespace() + + assert "McpdError" in namespace + assert "client" in namespace + assert namespace["client"] is function_builder.client + assert "Any" in namespace + assert "str" in namespace + assert "int" in namespace + + def test_clear_cache(self, function_builder): + # Add something to cache + function_builder._function_cache["test"] = {"data": "test"} + + function_builder.clear_cache() + + assert len(function_builder._function_cache) == 0 + + def test_build_function_code_structure(self, function_builder): + schema = { + "name": "test_tool", + "description": "Test tool", + "inputSchema": { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "Required parameter"}, + "param2": {"type": "string", "description": "Optional parameter"}, + }, + "required": ["param1"], + }, + } + + code = function_builder._build_function_code(schema, "test_server") + + assert "def test_server__test_tool(param1, param2=None):" in code + assert "required_params = ['param1']" in code + assert 'client._perform_call("test_server", "test_tool", params)' in code + assert "Test tool" in code + + def test_create_function_compilation_error(self, function_builder): + # Create a schema that would cause compilation issues + schema = { + "name": "test-tool", # This should be sanitized + "description": "Test tool", + "inputSchema": {"type": "object", "properties": {}, "required": []}, + } + + # Should not raise an error due to name sanitization + func = function_builder.create_function_from_schema(schema, "test-server") + assert func.__name__ == "test_server__test_tool" diff --git a/tests/unit/test_mcpd_client.py b/tests/unit/test_mcpd_client.py new file mode 100644 index 0000000..d16b00d --- /dev/null +++ b/tests/unit/test_mcpd_client.py @@ -0,0 +1,153 @@ +from unittest.mock import Mock, patch + +import pytest +from requests import Session +from requests.exceptions import RequestException + +from mcpd_sdk import McpdClient, McpdError + + +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 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" + + def test_init_strips_trailing_slash(self): + client = McpdClient("http://localhost:8090/") + assert client.endpoint == "http://localhost:8090" + + @patch.object(Session, "get") + def test_servers_success(self, mock_get, client, api_url): + servers = ["server1", "server2"] + mock_response = Mock() + mock_response.json.return_value = servers + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + result = client.servers() + + assert result == servers + mock_get.assert_called_once_with(f"{api_url}/servers", timeout=5) + + @patch.object(Session, "get") + def test_servers_request_error(self, mock_get, client): + mock_get.side_effect = RequestException("Connection failed") + + with pytest.raises(McpdError, match="Error listing servers"): + client.servers() + + @patch.object(Session, "get") + def test_tools_single_server(self, mock_get, client): + mock_response = Mock() + mock_response.json.return_value = {"tools": [{"name": "tool1"}, {"name": "tool2"}]} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + result = client.tools("test_server") + + assert result == [{"name": "tool1"}, {"name": "tool2"}] + mock_get.assert_called_once_with("http://localhost:8090/api/v1/servers/test_server/tools", timeout=5) + + @patch.object(Session, "get") + def test_tools_all_servers(self, mock_get, client): + # Mock servers() call + servers_response = Mock() + servers_response.json.return_value = ["server1", "server2"] + servers_response.raise_for_status.return_value = None + + # Mock tools calls for each server + tools_response = Mock() + tools_response.json.return_value = {"tools": [{"name": "tool1"}]} + tools_response.raise_for_status.return_value = None + + mock_get.side_effect = [servers_response, tools_response, tools_response] + + result = client.tools() + + assert result == {"server1": [{"name": "tool1"}], "server2": [{"name": "tool1"}]} + assert mock_get.call_count == 3 + + @patch.object(Session, "get") + def test_tools_request_error(self, mock_get, client): + mock_get.side_effect = RequestException("Connection failed") + + with pytest.raises(McpdError, match="Error listing tool definitions"): + client.tools("test_server") + + @patch.object(Session, "post") + def test_perform_call_success(self, mock_post, client): + mock_response = Mock() + mock_response.json.return_value = {"result": "success"} + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + result = client._perform_call("test_server", "test_tool", {"param": "value"}) + + assert result == {"result": "success"} + mock_post.assert_called_once_with( + "http://localhost:8090/api/v1/servers/test_server/tools/test_tool", json={"param": "value"}, timeout=30 + ) + + @patch.object(Session, "post") + def test_perform_call_request_error(self, mock_post, client): + mock_post.side_effect = RequestException("Connection failed") + + with pytest.raises(McpdError, match="Error calling tool 'test_tool' on server 'test_server'"): + client._perform_call("test_server", "test_tool", {"param": "value"}) + + @patch.object(McpdClient, "tools") + def test_agent_tools(self, mock_tools, client): + mock_tools.return_value = { + "server1": [{"name": "tool1", "description": "Test tool"}], + "server2": [{"name": "tool2", "description": "Another tool"}], + } + + with patch.object(client._function_builder, "create_function_from_schema") as mock_create: + mock_func1 = Mock() + mock_func2 = Mock() + mock_create.side_effect = [mock_func1, mock_func2] + + result = client.agent_tools() + + assert result == [mock_func1, mock_func2] + assert mock_create.call_count == 2 + + @patch.object(McpdClient, "tools") + def test_has_tool_exists(self, mock_tools, client): + mock_tools.return_value = [{"name": "existing_tool"}, {"name": "another_tool"}] + + result = client.has_tool("test_server", "existing_tool") + + assert result is True + mock_tools.assert_called_once_with(server_name="test_server") + + @patch.object(McpdClient, "tools") + def test_has_tool_not_exists(self, mock_tools, client): + mock_tools.return_value = [{"name": "existing_tool"}, {"name": "another_tool"}] + + result = client.has_tool("test_server", "nonexistent_tool") + + assert result is False + + @patch.object(McpdClient, "tools") + def test_has_tool_server_error(self, mock_tools, client): + mock_tools.side_effect = McpdError("Server error") + + result = client.has_tool("test_server", "any_tool") + + assert result is False + + def test_clear_agent_tools_cache(self, client): + with patch.object(client._function_builder, "clear_cache") as mock_clear: + client.clear_agent_tools_cache() + mock_clear.assert_called_once() diff --git a/tests/unit/test_type_converter.py b/tests/unit/test_type_converter.py new file mode 100644 index 0000000..5b05950 --- /dev/null +++ b/tests/unit/test_type_converter.py @@ -0,0 +1,139 @@ +from typing import Any, Literal + +from mcpd_sdk.type_converter import TypeConverter + + +class TestTypeConverter: + def test_json_type_to_python_type_string(self): + result = TypeConverter.json_type_to_python_type("string", {}) + assert result == str + + def test_json_type_to_python_type_string_with_enum(self): + schema = {"enum": ["option1", "option2", "option3"]} + result = TypeConverter.json_type_to_python_type("string", schema) + + # Should be a Literal type + assert hasattr(result, "__origin__") + assert result.__origin__ is Literal + + def test_json_type_to_python_type_string_with_single_enum(self): + schema = {"enum": ["single_option"]} + result = TypeConverter.json_type_to_python_type("string", schema) + + # Should handle single enum value + assert hasattr(result, "__origin__") or result == Literal["single_option"] + + def test_json_type_to_python_type_number(self): + result = TypeConverter.json_type_to_python_type("number", {}) + assert result == (int | float) + + def test_json_type_to_python_type_integer(self): + result = TypeConverter.json_type_to_python_type("integer", {}) + assert result == int + + def test_json_type_to_python_type_boolean(self): + result = TypeConverter.json_type_to_python_type("boolean", {}) + assert result == bool + + def test_json_type_to_python_type_array_with_items(self): + schema = {"items": {"type": "string"}} + result = TypeConverter.json_type_to_python_type("array", schema) + assert result == list[str] + + def test_json_type_to_python_type_array_without_items(self): + result = TypeConverter.json_type_to_python_type("array", {}) + assert result == list[Any] + + def test_json_type_to_python_type_object(self): + result = TypeConverter.json_type_to_python_type("object", {}) + assert result == dict[str, Any] + + def test_json_type_to_python_type_unknown(self): + result = TypeConverter.json_type_to_python_type("unknown_type", {}) + assert result == Any + + def test_parse_schema_type_simple_type(self): + schema = {"type": "string"} + result = TypeConverter.parse_schema_type(schema) + assert result == str + + def test_parse_schema_type_anyof_simple(self): + schema = {"anyOf": [{"type": "string"}, {"type": "integer"}]} + result = TypeConverter.parse_schema_type(schema) + assert result == (str | int) + + def test_parse_schema_type_anyof_complex(self): + schema = {"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "boolean"}]} + result = TypeConverter.parse_schema_type(schema) + assert result == (str | int | bool) + + def test_parse_schema_type_anyof_with_arrays(self): + schema = {"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "integer"}}]} + result = TypeConverter.parse_schema_type(schema) + assert result == (str | list[int]) + + def test_parse_schema_type_nested_anyof(self): + schema = {"anyOf": [{"type": "string"}, {"anyOf": [{"type": "integer"}, {"type": "boolean"}]}]} + result = TypeConverter.parse_schema_type(schema) + # Should handle nested anyOf + assert result == (str | (int | bool)) + + def test_parse_schema_type_no_type_field(self): + schema = {"description": "Some schema without type"} + result = TypeConverter.parse_schema_type(schema) + assert result == Any + + def test_parse_schema_type_empty_schema(self): + schema = {} + result = TypeConverter.parse_schema_type(schema) + assert result == Any + + def test_parse_schema_type_array_with_complex_items(self): + schema = {"type": "array", "items": {"type": "object"}} + result = TypeConverter.parse_schema_type(schema) + assert result == list[dict[str, Any]] + + def test_parse_schema_type_enum_with_mixed_types(self): + # Test enum with different value types + schema = {"type": "string", "enum": ["string_val", "another_string"]} + result = TypeConverter.parse_schema_type(schema) + + # Should be a Literal type + assert hasattr(result, "__origin__") + assert result.__origin__ is Literal + + def test_complex_nested_schema(self): + schema = { + "type": "object", + "properties": { + "nested_array": {"type": "array", "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}} + }, + } + # This tests the overall parsing, not specific property parsing + result = TypeConverter.parse_schema_type(schema) + assert result == dict[str, Any] + + def test_array_with_anyof_items(self): + schema = {"type": "array", "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}} + result = TypeConverter.parse_schema_type(schema) + assert result == list[str | int] + + def test_enum_fallback_handling(self): + # Test the fallback mechanism for complex enum handling + schema = {"type": "string", "enum": ["val1", "val2", "val3", "val4"]} + result = TypeConverter.parse_schema_type(schema) + + # Should handle the enum properly + assert hasattr(result, "__origin__") + assert result.__origin__ is Literal + + def test_json_type_to_python_type_array_recursive(self): + schema = {"items": {"type": "array", "items": {"type": "string"}}} + result = TypeConverter.json_type_to_python_type("array", schema) + assert result == list[list[str]] + + def test_parse_schema_type_with_null_in_anyof(self): + schema = {"anyOf": [{"type": "string"}, {"type": "null"}]} + result = TypeConverter.parse_schema_type(schema) + # Should handle null type properly + assert result == (str | Any) # null maps to Any in this implementation diff --git a/uv.lock b/uv.lock index f6dc1fb..2e1fbec 100644 --- a/uv.lock +++ b/uv.lock @@ -145,8 +145,7 @@ wheels = [ [[package]] name = "mcpd-sdk" -version = "0.0.1" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "requests" }, ] @@ -157,12 +156,16 @@ all = [ { name = "pre-commit" }, { name = "pytest" }, { name = "ruff" }, + { name = "setuptools" }, + { name = "setuptools-scm" }, ] dev = [ { name = "debugpy" }, { name = "pre-commit" }, { name = "pytest" }, { name = "ruff" }, + { name = "setuptools" }, + { name = "setuptools-scm" }, ] lint = [ { name = "pre-commit" }, @@ -170,6 +173,8 @@ lint = [ ] tests = [ { name = "pytest" }, + { name = "setuptools" }, + { name = "setuptools-scm" }, ] [package.metadata] @@ -181,18 +186,26 @@ all = [ { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "ruff", specifier = ">=0.11.13" }, + { name = "setuptools", specifier = ">=80.9.0" }, + { name = "setuptools-scm", extras = ["toml"], specifier = ">=8.3.1" }, ] dev = [ { name = "debugpy", specifier = ">=1.8.14" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "ruff", specifier = ">=0.11.13" }, + { name = "setuptools", specifier = ">=80.9.0" }, + { name = "setuptools-scm", extras = ["toml"], specifier = ">=8.3.1" }, ] lint = [ { name = "pre-commit", specifier = ">=4.2.0" }, { name = "ruff", specifier = ">=0.11.13" }, ] -tests = [{ name = "pytest", specifier = ">=8.3.5" }] +tests = [ + { name = "pytest", specifier = ">=8.3.5" }, + { name = "setuptools", specifier = ">=80.9.0" }, + { name = "setuptools-scm", extras = ["toml"], specifier = ">=8.3.1" }, +] [[package]] name = "nodeenv" @@ -346,6 +359,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, ] +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "setuptools-scm" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/19/7ae64b70b2429c48c3a7a4ed36f50f94687d3bfcd0ae2f152367b6410dff/setuptools_scm-8.3.1.tar.gz", hash = "sha256:3d555e92b75dacd037d32bafdf94f97af51ea29ae8c7b234cf94b7a5bd242a63", size = 78088, upload-time = "2025-04-23T11:53:19.739Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/ac/8f96ba9b4cfe3e4ea201f23f4f97165862395e9331a424ed325ae37024a8/setuptools_scm-8.3.1-py3-none-any.whl", hash = "sha256:332ca0d43791b818b841213e76b1971b7711a960761c5bea5fc5cdb5196fbce3", size = 43935, upload-time = "2025-04-23T11:53:17.922Z" }, +] + [[package]] name = "urllib3" version = "2.4.0"