From f044de652416799323f14b48c7079185923b852b Mon Sep 17 00:00:00 2001 From: Chris Boffa Date: Thu, 12 Feb 2026 13:51:00 -0500 Subject: [PATCH 1/2] feat(client): add common oci client library --- src/common/LICENSE.txt | 3 + src/common/README.md | 17 + src/common/oracle/__init__.py | 9 + src/common/oracle/mcp_common/__init__.py | 9 + src/common/oracle/mcp_common/helpers.py | 139 +++++ .../oracle/mcp_common/tests/test_helpers.py | 226 ++++++++ src/common/pyproject.toml | 44 ++ src/common/uv.lock | 536 ++++++++++++++++++ .../oracle/__init__.py | 4 + .../oracle/oci_identity_mcp_server/server.py | 60 +- .../tests/test_identity_tools.py | 295 +++------- src/oci-identity-mcp-server/pyproject.toml | 6 + 12 files changed, 1091 insertions(+), 257 deletions(-) create mode 100644 src/common/LICENSE.txt create mode 100644 src/common/README.md create mode 100644 src/common/oracle/__init__.py create mode 100644 src/common/oracle/mcp_common/__init__.py create mode 100644 src/common/oracle/mcp_common/helpers.py create mode 100644 src/common/oracle/mcp_common/tests/test_helpers.py create mode 100644 src/common/pyproject.toml create mode 100644 src/common/uv.lock diff --git a/src/common/LICENSE.txt b/src/common/LICENSE.txt new file mode 100644 index 00000000..89e14826 --- /dev/null +++ b/src/common/LICENSE.txt @@ -0,0 +1,3 @@ +Copyright (c) 2026, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. diff --git a/src/common/README.md b/src/common/README.md new file mode 100644 index 00000000..281b501e --- /dev/null +++ b/src/common/README.md @@ -0,0 +1,17 @@ +# oracle-mcp-common + +Shared utilities for Oracle MCP servers. This package provides helpers and decorators that can be reused across the various OCI MCP server implementations. + +## Usage + +Install the package in editable mode from the repository root: + +```bash +pip install -e src/common +``` + +Then import helpers as needed, for example: + +```python +from oracle.mcp_common.helpers import with_oci_client +``` diff --git a/src/common/oracle/__init__.py b/src/common/oracle/__init__.py new file mode 100644 index 00000000..a8e8fb3d --- /dev/null +++ b/src/common/oracle/__init__.py @@ -0,0 +1,9 @@ +""" +Copyright (c) 2026, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" + +from pkgutil import extend_path + +__path__ = extend_path(__path__, __name__) diff --git a/src/common/oracle/mcp_common/__init__.py b/src/common/oracle/mcp_common/__init__.py new file mode 100644 index 00000000..36cb02ee --- /dev/null +++ b/src/common/oracle/mcp_common/__init__.py @@ -0,0 +1,9 @@ +""" +Copyright (c) 2026, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" + +from .helpers import with_oci_client + +__all__ = ["with_oci_client"] diff --git a/src/common/oracle/mcp_common/helpers.py b/src/common/oracle/mcp_common/helpers.py new file mode 100644 index 00000000..e4ee1eb7 --- /dev/null +++ b/src/common/oracle/mcp_common/helpers.py @@ -0,0 +1,139 @@ +import importlib +import inspect +import os +from functools import wraps +from logging import Logger +from typing import Any, Callable, TypeVar + +import oci + +ClientT = TypeVar("ClientT") + +logger = Logger(__name__, level="INFO") + + +def _create_oci_client( + client_class: type[ClientT], project: str, version: str, tool_name: str +) -> ClientT: + """ + Creates a client from the given OCI SDK class + + :param client_class: The OCI SDK Class + :type client_class: type[ClientT] + :param project: Project Name + :type project: str + :param version: Project Version + :type version: str + :return: Returns the configured client class + :rtype: ClientT + """ + logger.info("entering _create_oci_client") + config = oci.config.from_file( + file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), + profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), + ) + user_agent_name = project.split("oracle.", 1)[1].split("-server", 1)[0] + config["additional_user_agent"] = f"{user_agent_name}/{version} ({tool_name})" + + private_key = oci.signer.load_private_key_from_file(config["key_file"]) + token_file = os.path.expanduser(config["security_token_file"]) + token = None + with open(token_file, "r") as f: + token = f.read() + signer = oci.auth.signers.SecurityTokenSigner(token, private_key) + return client_class(config, signer=signer) + + +def _resolve_project_metadata(func: Callable[..., Any]) -> tuple[str, str]: + """ + This function automatically finds the package name and version of a calling function + + :param func: The given function that is being called + :type func: Callable[..., Any] + :return: Returns a project and version of the package + :rtype: tuple[str, str] + """ + module = inspect.getmodule(func) + if module is None: + raise RuntimeError( + "Unable to determine module for function decorated with with_oci_client" + ) + + project = getattr(module, "__project__", None) + version = getattr(module, "__version__", None) + + package_name = module.__package__ or module.__name__.rpartition(".")[0] + + while (project is None or version is None) and package_name: + package = importlib.import_module(package_name) + project = project or getattr(package, "__project__", None) + version = version or getattr(package, "__version__", None) + if "." in package_name: + package_name = package_name.rsplit(".", 1)[0] + else: + package_name = "" + + if project is None or version is None: + raise RuntimeError( + "Unable to resolve __project__ and __version__ for with_oci_client decorated function" + ) + + return project, version + + +def with_oci_client( + client_class: type[ClientT], +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """ + A decorator which configures the OCI client with metadata + + :param client_class: The client class from the OCI SDK + :type client_class: type[ClientT] + :return: Configured client + :rtype: Callable[[Callable[..., Any]], Callable[..., Any]] + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + signature = inspect.signature(func) + + if "client" not in signature.parameters: + raise ValueError( + "with_oci_client decorator requires a 'client' parameter in the function signature" + ) + + project, version = _resolve_project_metadata(func) + + def _ensure_client(kwargs: dict[str, Any]) -> dict[str, Any]: + if kwargs.get("client") is None: + kwargs = dict(kwargs) + kwargs["client"] = _create_oci_client( + client_class, project, version, func.__qualname__ + ) + return kwargs + + if inspect.iscoroutinefunction(func): + + @wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + kwargs = _ensure_client(kwargs) + return await func(*args, **kwargs) + + wrapper: Callable[..., Any] = async_wrapper + else: + + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + kwargs = _ensure_client(kwargs) + return func(*args, **kwargs) + + parameters_without_client = [ + parameter + for name, parameter in signature.parameters.items() + if name != "client" + ] + wrapper.__signature__ = signature.replace( + parameters=tuple(parameters_without_client) + ) + return wrapper + + return decorator diff --git a/src/common/oracle/mcp_common/tests/test_helpers.py b/src/common/oracle/mcp_common/tests/test_helpers.py new file mode 100644 index 00000000..9a3cf5f0 --- /dev/null +++ b/src/common/oracle/mcp_common/tests/test_helpers.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import builtins +import inspect +import sys +from types import ModuleType, SimpleNamespace +from typing import Any + +import pytest +from oracle.mcp_common import helpers + + +class DummyClient: + def __init__(self, config: dict[str, Any], signer: Any) -> None: + self.config = config + self.signer = signer + + +class DummySigner: + def __init__(self, token: str, private_key: str) -> None: + self.token = token + self.private_key = private_key + + +def test_create_oci_client(monkeypatch, tmp_path): + token_path = tmp_path / "token" + token_path.write_text("TOKEN") + key_path = tmp_path / "key.pem" + key_path.write_text("PRIVATE KEY") + + config = { + "key_file": str(key_path), + "security_token_file": str(token_path), + } + + captured_config = {} + + def fake_from_file(file_location, profile_name): + captured_config["file_location"] = file_location + captured_config["profile_name"] = profile_name + return config + + def fake_load_private_key(path): + assert path == str(key_path) + return "PRIVATE" + + def fake_expanduser(path): + assert path == str(token_path) + return path + + original_open = builtins.open + + def fake_open(path, mode): + assert mode == "r" + assert str(path) == str(token_path) + return original_open(token_path, mode) + + monkeypatch.setattr(helpers.oci.config, "from_file", fake_from_file) + monkeypatch.setattr( + helpers.oci.signer, "load_private_key_from_file", fake_load_private_key + ) + monkeypatch.setattr(helpers.os.path, "expanduser", fake_expanduser) + monkeypatch.setattr(builtins, "open", fake_open) + monkeypatch.setattr(helpers.oci.auth.signers, "SecurityTokenSigner", DummySigner) + monkeypatch.setenv("OCI_CONFIG_FILE", "~/.oci/config") + monkeypatch.setenv("OCI_CONFIG_PROFILE", "DEFAULT") + + client = helpers._create_oci_client( + DummyClient, "oracle.example-server", "1.0.0", "sample_tool" + ) + + assert isinstance(client, DummyClient) + assert client.config == config + assert client.signer.token == "TOKEN" + assert client.signer.private_key == "PRIVATE" + assert config["additional_user_agent"] == "example/1.0.0 (sample_tool)" + assert captured_config["file_location"] == "~/.oci/config" + assert captured_config["profile_name"] == "DEFAULT" + + +def test_resolve_project_metadata_direct_attr(monkeypatch): + module = ModuleType("oracle.product.module") + module.__dict__["__project__"] = "oracle.product-server" + module.__dict__["__version__"] = "2.1.3" + + def sample(client=None): + return client + + sample.__module__ = module.__name__ + + monkeypatch.setitem(sys.modules, module.__name__, module) + func = SimpleNamespace(__module__=module.__name__) + func.__call__ = sample + module.__dict__["sample"] = sample + + project, version = helpers._resolve_project_metadata(sample) + + assert project == "oracle.product-server" + assert version == "2.1.3" + + +def test_resolve_project_metadata_parent_lookup(monkeypatch): + parent_module = ModuleType("oracle.parent") + parent_module.__dict__["__project__"] = "oracle.parent-server" + parent_module.__dict__["__version__"] = "3.0.0" + + child_module = ModuleType("oracle.parent.child") + child_module.__dict__["__project__"] = None + child_module.__dict__["__version__"] = None + child_module.__dict__["__package__"] = "oracle.parent" + + monkeypatch.setitem(sys.modules, parent_module.__name__, parent_module) + monkeypatch.setitem(sys.modules, child_module.__name__, child_module) + + def func(client=None): + return client + + func.__module__ = child_module.__name__ + child_module.__dict__["func"] = func + parent_module.__dict__["child"] = child_module + + project, version = helpers._resolve_project_metadata(func) + + assert project == "oracle.parent-server" + assert version == "3.0.0" + + +def test_resolve_project_metadata_failure(monkeypatch): + module = ModuleType("no_meta") + module.__dict__["__project__"] = None + module.__dict__["__version__"] = None + monkeypatch.setitem(sys.modules, module.__name__, module) + + def func(client=None): + return client + + func.__module__ = module.__name__ + module.__dict__["func"] = func + + with pytest.raises(RuntimeError): + helpers._resolve_project_metadata(func) + + +def test_with_oci_client_missing_client_param(): + with pytest.raises(ValueError): + + @helpers.with_oci_client(DummyClient) + def no_client_argument(): + return None + + +def test_with_oci_client_sync_injection(monkeypatch): + def func(*, client): + return client + + module = ModuleType("oracle.meta") + module.__dict__["__project__"] = "oracle.meta-server" + module.__dict__["__version__"] = "1.2.3" + func.__module__ = module.__name__ + monkeypatch.setitem(sys.modules, module.__name__, module) + + dummy_client = object() + + def fake_create_client(client_class, project, version, tool_name): + assert project == "oracle.meta-server" + assert version == "1.2.3" + assert tool_name == "test_with_oci_client_sync_injection..func" + return dummy_client + + monkeypatch.setattr(helpers, "_create_oci_client", fake_create_client) + + wrapped = helpers.with_oci_client(DummyClient)(func) + + result = wrapped() + + assert result is dummy_client + assert "client" not in inspect.signature(wrapped).parameters + + +def test_with_oci_client_existing_client(monkeypatch): + module = ModuleType("oracle.meta2") + module.__dict__["__project__"] = "oracle.meta2-server" + module.__dict__["__version__"] = "4.5.6" + + def func(*, client): + return client + + func.__module__ = module.__name__ + monkeypatch.setitem(sys.modules, module.__name__, module) + + sentinel = object() + + wrapped = helpers.with_oci_client(DummyClient)(func) + result = wrapped(client=sentinel) + + assert result is sentinel + + +@pytest.mark.asyncio +async def test_with_oci_client_async(monkeypatch): + module = ModuleType("oracle.meta.async") + module.__dict__["__project__"] = "oracle.meta-async-server" + module.__dict__["__version__"] = "7.8.9" + + async def func(*, client): + return client + + func.__module__ = module.__name__ + monkeypatch.setitem(sys.modules, module.__name__, module) + + dummy_client = object() + + def fake_create_client(client_class, project, version, tool_name): + assert project == "oracle.meta-async-server" + assert version == "7.8.9" + assert tool_name == "test_with_oci_client_async..func" + return dummy_client + + monkeypatch.setattr(helpers, "_create_oci_client", fake_create_client) + + wrapped = helpers.with_oci_client(DummyClient)(func) + + result = await wrapped() + + assert result is dummy_client + assert "client" not in inspect.signature(wrapped).parameters diff --git a/src/common/pyproject.toml b/src/common/pyproject.toml new file mode 100644 index 00000000..0b343d3e --- /dev/null +++ b/src/common/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "mcp-common" +version = "0.1.0" +description = "Shared utilities for Oracle MCP servers" +readme = "README.md" +requires-python = ">=3.10" +license = "UPL-1.0" +license-files = ["LICENSE.txt"] +authors = [ + {name = "Oracle MCP", email = "237432095+oracle-mcp@users.noreply.github.com"}, +] +dependencies = [ + "oci==2.160.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["oracle"] + +[dependency-groups] +dev = [ + "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", + "pytest-cov>=7.0.0", +] + +[tool.coverage.run] +omit = [ + "**/__init__.py", + "**/tests/*", + "dist/*", + ".venv/*", +] + +[tool.coverage.report] +omit = [ + "**/__init__.py", + "**/tests/*", +] +precision = 2 +fail_under = 90 diff --git a/src/common/uv.lock b/src/common/uv.lock new file mode 100644 index 00000000..d55a4a4c --- /dev/null +++ b/src/common/uv.lock @@ -0,0 +1,536 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "circuitbreaker" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/ac/de7a92c4ed39cba31fe5ad9203b76a25ca67c530797f6bb420fff5f65ccb/circuitbreaker-2.1.3.tar.gz", hash = "sha256:1a4baee510f7bea3c91b194dcce7c07805fe96c4423ed5594b75af438531d084", size = 10787, upload-time = "2025-03-31T08:12:08.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/34/15f08edd4628f65217de1fc3c1a27c82e46fe357d60c217fc9881e12ebcc/circuitbreaker-2.1.3-py3-none-any.whl", hash = "sha256:87ba6a3ed03fdc7032bc175561c2b04d52ade9d5faf94ca2b035fbdc5e6b1dd1", size = 7737, upload-time = "2025-03-31T08:12:07.802Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "44.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/d6/1411ab4d6108ab167d06254c5be517681f1e331f90edf1379895bcb87020/cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053", size = 711096, upload-time = "2025-05-02T19:36:04.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/53/c776d80e9d26441bb3868457909b4e74dd9ccabd182e10b2b0ae7a07e265/cryptography-44.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88", size = 6670281, upload-time = "2025-05-02T19:34:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/6a/06/af2cf8d56ef87c77319e9086601bef621bedf40f6f59069e1b6d1ec498c5/cryptography-44.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137", size = 3959305, upload-time = "2025-05-02T19:34:53.042Z" }, + { url = "https://files.pythonhosted.org/packages/ae/01/80de3bec64627207d030f47bf3536889efee8913cd363e78ca9a09b13c8e/cryptography-44.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c", size = 4171040, upload-time = "2025-05-02T19:34:54.675Z" }, + { url = "https://files.pythonhosted.org/packages/bd/48/bb16b7541d207a19d9ae8b541c70037a05e473ddc72ccb1386524d4f023c/cryptography-44.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76", size = 3963411, upload-time = "2025-05-02T19:34:56.61Z" }, + { url = "https://files.pythonhosted.org/packages/42/b2/7d31f2af5591d217d71d37d044ef5412945a8a8e98d5a2a8ae4fd9cd4489/cryptography-44.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359", size = 3689263, upload-time = "2025-05-02T19:34:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/25/50/c0dfb9d87ae88ccc01aad8eb93e23cfbcea6a6a106a9b63a7b14c1f93c75/cryptography-44.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43", size = 4196198, upload-time = "2025-05-02T19:35:00.988Z" }, + { url = "https://files.pythonhosted.org/packages/66/c9/55c6b8794a74da652690c898cb43906310a3e4e4f6ee0b5f8b3b3e70c441/cryptography-44.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01", size = 3966502, upload-time = "2025-05-02T19:35:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f7/7cb5488c682ca59a02a32ec5f975074084db4c983f849d47b7b67cc8697a/cryptography-44.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d", size = 4196173, upload-time = "2025-05-02T19:35:05.018Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0b/2f789a8403ae089b0b121f8f54f4a3e5228df756e2146efdf4a09a3d5083/cryptography-44.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904", size = 4087713, upload-time = "2025-05-02T19:35:07.187Z" }, + { url = "https://files.pythonhosted.org/packages/1d/aa/330c13655f1af398fc154089295cf259252f0ba5df93b4bc9d9c7d7f843e/cryptography-44.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44", size = 4299064, upload-time = "2025-05-02T19:35:08.879Z" }, + { url = "https://files.pythonhosted.org/packages/10/a8/8c540a421b44fd267a7d58a1fd5f072a552d72204a3f08194f98889de76d/cryptography-44.0.3-cp37-abi3-win32.whl", hash = "sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d", size = 2773887, upload-time = "2025-05-02T19:35:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0d/c4b1657c39ead18d76bbd122da86bd95bdc4095413460d09544000a17d56/cryptography-44.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d", size = 3209737, upload-time = "2025-05-02T19:35:12.12Z" }, + { url = "https://files.pythonhosted.org/packages/34/a3/ad08e0bcc34ad436013458d7528e83ac29910943cea42ad7dd4141a27bbb/cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f", size = 6673501, upload-time = "2025-05-02T19:35:13.775Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f0/7491d44bba8d28b464a5bc8cc709f25a51e3eac54c0a4444cf2473a57c37/cryptography-44.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759", size = 3960307, upload-time = "2025-05-02T19:35:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/e5c5d0e1364d3346a5747cdcd7ecbb23ca87e6dea4f942a44e88be349f06/cryptography-44.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645", size = 4170876, upload-time = "2025-05-02T19:35:18.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/96/025cb26fc351d8c7d3a1c44e20cf9a01e9f7cf740353c9c7a17072e4b264/cryptography-44.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2", size = 3964127, upload-time = "2025-05-02T19:35:19.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/44/eb6522db7d9f84e8833ba3bf63313f8e257729cf3a8917379473fcfd6601/cryptography-44.0.3-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54", size = 3689164, upload-time = "2025-05-02T19:35:21.449Z" }, + { url = "https://files.pythonhosted.org/packages/68/fb/d61a4defd0d6cee20b1b8a1ea8f5e25007e26aeb413ca53835f0cae2bcd1/cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93", size = 4198081, upload-time = "2025-05-02T19:35:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/1b/50/457f6911d36432a8811c3ab8bd5a6090e8d18ce655c22820994913dd06ea/cryptography-44.0.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c", size = 3967716, upload-time = "2025-05-02T19:35:25.426Z" }, + { url = "https://files.pythonhosted.org/packages/35/6e/dca39d553075980ccb631955c47b93d87d27f3596da8d48b1ae81463d915/cryptography-44.0.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f", size = 4197398, upload-time = "2025-05-02T19:35:27.678Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9d/d1f2fe681eabc682067c66a74addd46c887ebacf39038ba01f8860338d3d/cryptography-44.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5", size = 4087900, upload-time = "2025-05-02T19:35:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f5/3599e48c5464580b73b236aafb20973b953cd2e7b44c7c2533de1d888446/cryptography-44.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b", size = 4301067, upload-time = "2025-05-02T19:35:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/d2c48c8137eb39d0c193274db5c04a75dab20d2f7c3f81a7dcc3a8897701/cryptography-44.0.3-cp39-abi3-win32.whl", hash = "sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028", size = 2775467, upload-time = "2025-05-02T19:35:33.805Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ad/51f212198681ea7b0deaaf8846ee10af99fba4e894f67b353524eab2bbe5/cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334", size = 3210375, upload-time = "2025-05-02T19:35:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/7f/10/abcf7418536df1eaba70e2cfc5c8a0ab07aa7aa02a5cbc6a78b9d8b4f121/cryptography-44.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:cad399780053fb383dc067475135e41c9fe7d901a97dd5d9c5dfb5611afc0d7d", size = 3393192, upload-time = "2025-05-02T19:35:37.468Z" }, + { url = "https://files.pythonhosted.org/packages/06/59/ecb3ef380f5891978f92a7f9120e2852b1df6f0a849c277b8ea45b865db2/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:21a83f6f35b9cc656d71b5de8d519f566df01e660ac2578805ab245ffd8523f8", size = 3898419, upload-time = "2025-05-02T19:35:39.065Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d0/35e2313dbb38cf793aa242182ad5bc5ef5c8fd4e5dbdc380b936c7d51169/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4", size = 4117892, upload-time = "2025-05-02T19:35:40.839Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c8/31fb6e33b56c2c2100d76de3fd820afaa9d4d0b6aea1ccaf9aaf35dc7ce3/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:e909df4053064a97f1e6565153ff8bb389af12c5c8d29c343308760890560aff", size = 3900855, upload-time = "2025-05-02T19:35:42.599Z" }, + { url = "https://files.pythonhosted.org/packages/43/2a/08cc2ec19e77f2a3cfa2337b429676406d4bb78ddd130a05c458e7b91d73/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dad80b45c22e05b259e33ddd458e9e2ba099c86ccf4e88db7bbab4b747b18d06", size = 4117619, upload-time = "2025-05-02T19:35:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/fc3d3f84022a75f2ac4b1a1c0e5d6a0c2ea259e14cd4aae3e0e68e56483c/cryptography-44.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:479d92908277bed6e1a1c69b277734a7771c2b78633c224445b5c60a9f4bc1d9", size = 3136570, upload-time = "2025-05-02T19:35:46.94Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4b/c11ad0b6c061902de5223892d680e89c06c7c4d606305eb8de56c5427ae6/cryptography-44.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375", size = 3390230, upload-time = "2025-05-02T19:35:49.062Z" }, + { url = "https://files.pythonhosted.org/packages/58/11/0a6bf45d53b9b2290ea3cec30e78b78e6ca29dc101e2e296872a0ffe1335/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647", size = 3895216, upload-time = "2025-05-02T19:35:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/0a/27/b28cdeb7270e957f0077a2c2bfad1b38f72f1f6d699679f97b816ca33642/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259", size = 4115044, upload-time = "2025-05-02T19:35:53.044Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/ec4082d3793f03cb248881fecefc26015813199b88f33e3e990a43f79835/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff", size = 3898034, upload-time = "2025-05-02T19:35:54.72Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7f/adf62e0b8e8d04d50c9a91282a57628c00c54d4ae75e2b02a223bd1f2613/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5", size = 4114449, upload-time = "2025-05-02T19:35:57.139Z" }, + { url = "https://files.pythonhosted.org/packages/87/62/d69eb4a8ee231f4bf733a92caf9da13f1c81a44e874b1d4080c25ecbb723/cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c", size = 3134369, upload-time = "2025-05-02T19:35:58.907Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mcp-common" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "oci" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [{ name = "oci", specifier = "==2.160.0" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, +] + +[[package]] +name = "oci" +version = "2.160.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "circuitbreaker" }, + { name = "cryptography" }, + { name = "pyopenssl" }, + { name = "python-dateutil" }, + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/7b/c9d7fc28f11c25c7875db3584eab5d52ccb2d7df553d07ac47f19a14d075/oci-2.160.0.tar.gz", hash = "sha256:f8e3410204c1405b40247179550cf74f5145a8e17025c4f2a92f2b9ffdc7d26b", size = 15601606, upload-time = "2025-09-09T04:17:43.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/51/752375a4e0d2de371c2788414157eda337417010d2ef7383cd7140388f1e/oci-2.160.0-py3-none-any.whl", hash = "sha256:3dba1ec671ebea23f255fabf836cb0fd08aea0913a8df85610fccaa5a4344ee9", size = 31715365, upload-time = "2025-09-09T04:17:34.998Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyopenssl" +version = "24.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/d4/1067b82c4fc674d6f6e9e8d26b3dff978da46d351ca3bac171544693e085/pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36", size = 178944, upload-time = "2024-11-27T20:43:12.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/22/40f9162e943f86f0fc927ebc648078be87def360d9d8db346619fb97df2b/pyOpenSSL-24.3.0-py3-none-any.whl", hash = "sha256:e474f5a473cd7f92221cc04976e48f4d11502804657a08a989fb3be5514c904a", size = 56111, upload-time = "2024-11-27T20:43:21.112Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] diff --git a/src/oci-identity-mcp-server/oracle/__init__.py b/src/oci-identity-mcp-server/oracle/__init__.py index d9dff098..86b332d3 100644 --- a/src/oci-identity-mcp-server/oracle/__init__.py +++ b/src/oci-identity-mcp-server/oracle/__init__.py @@ -3,3 +3,7 @@ Licensed under the Universal Permissive License v1.0 as shown at https://oss.oracle.com/licenses/upl. """ + +from pkgutil import extend_path + +__path__ = extend_path(__path__, __name__) diff --git a/src/oci-identity-mcp-server/oracle/oci_identity_mcp_server/server.py b/src/oci-identity-mcp-server/oracle/oci_identity_mcp_server/server.py index d93062cc..a78d7f48 100644 --- a/src/oci-identity-mcp-server/oracle/oci_identity_mcp_server/server.py +++ b/src/oci-identity-mcp-server/oracle/oci_identity_mcp_server/server.py @@ -12,6 +12,7 @@ import oci from fastmcp import FastMCP +from oracle.mcp_common import with_oci_client from oracle.oci_identity_mcp_server.models import ( AuthToken, AvailabilityDomain, @@ -28,29 +29,15 @@ ) from pydantic import Field -from . import __project__, __version__ +from . import __project__ logger = Logger(__name__, level="INFO") mcp = FastMCP(name=__project__) -def get_identity_client(): - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - user_agent_name = __project__.split("oracle.", 1)[1].split("-server", 1)[0] - config["additional_user_agent"] = f"{user_agent_name}/{__version__}" - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = os.path.expanduser(config["security_token_file"]) - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - return oci.identity.IdentityClient(config, signer=signer) - - @mcp.tool(description="List compartments in a given compartment or tenancy.") +@with_oci_client(oci.identity.IdentityClient) def list_compartments( compartment_id: str = Field( ..., @@ -80,12 +67,12 @@ def list_compartments( description="The maximum amount of compartments to return. If None, there is no limit.", ge=1, ), + *, + client: oci.identity.IdentityClient, ) -> list[Compartment]: compartments: list[Compartment] = [] try: - client = get_identity_client() - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -129,12 +116,13 @@ def list_compartments( @mcp.tool(description="Get tenancy with a given OCID") +@with_oci_client(oci.identity.IdentityClient) def get_tenancy( - tenancy_id: str = Field(..., description="The OCID of the tenancy") + tenancy_id: str = Field(..., description="The OCID of the tenancy"), + *, + client: oci.identity.IdentityClient, ) -> Tenancy: try: - client = get_identity_client() - response: oci.response.Response = client.get_tenancy(tenancy_id) data: oci.identity.models.Tenancy = response.data logger.info("Found Tenancy") @@ -146,17 +134,18 @@ def get_tenancy( @mcp.tool(description="Lists all of the availability domains in a given tenancy") +@with_oci_client(oci.identity.IdentityClient) def list_availability_domains( compartment_id: str = Field( ..., description="The OCID of the compartment (remember that the tenancy is simply the root compartment)", ), + *, + client: oci.identity.IdentityClient, ) -> list[AvailabilityDomain]: ads: list[AvailabilityDomain] = [] try: - client = get_identity_client() - response = client.list_availability_domains(compartment_id) data: list[oci.identity.models.AvailabilityDomain] = response.data @@ -172,10 +161,9 @@ def list_availability_domains( @mcp.tool -def get_current_tenancy() -> Tenancy: +@with_oci_client(oci.identity.IdentityClient) +def get_current_tenancy(*, client: oci.identity.IdentityClient) -> Tenancy: try: - client = get_identity_client() - config = oci.config.from_file( file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), @@ -192,15 +180,16 @@ def get_current_tenancy() -> Tenancy: @mcp.tool +@with_oci_client(oci.identity.IdentityClient) def create_auth_token( user_id: str = Field(..., description="The OCID of the user"), description: Optional[str] = Field( "", description="The description of the auth token" ), + *, + client: oci.identity.IdentityClient, ) -> AuthToken: try: - client = get_identity_client() - create_auth_token_details = oci.identity.models.CreateAuthTokenDetails( description=description ) @@ -219,9 +208,9 @@ def create_auth_token( @mcp.tool -def get_current_user() -> User: +@with_oci_client(oci.identity.IdentityClient) +def get_current_user(*, client: oci.identity.IdentityClient) -> User: try: - client = get_identity_client() config = oci.config.from_file( file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), @@ -270,19 +259,20 @@ def get_current_user() -> User: @mcp.tool( description="Get a specific compartment by its name within a parent compartment." ) +@with_oci_client(oci.identity.IdentityClient) def get_compartment_by_name( name: str = Field(description="The name of the compartment to find."), parent_compartment_id: str = Field( description="The OCID of the parent compartment to search within (or tenancy OCID).", ), + *, + client: oci.identity.IdentityClient, ) -> Optional[Compartment]: """ Searches for a compartment by name within a specific parent compartment. Note: This is not a recursive search; it only looks at direct children. """ try: - client = get_identity_client() - has_next_page = True next_page: str = None @@ -313,13 +303,15 @@ def get_compartment_by_name( @mcp.tool(description="List the regions a tenancy is subscribed to.") +@with_oci_client(oci.identity.IdentityClient) def list_subscribed_regions( - tenancy_id: str = Field(..., description="The OCID of the tenancy.") + tenancy_id: str = Field(..., description="The OCID of the tenancy."), + *, + client: oci.identity.IdentityClient, ) -> list[RegionSubscription]: regions: list[RegionSubscription] = [] try: - client = get_identity_client() response = client.list_region_subscriptions(tenancy_id=tenancy_id) data: list[oci.identity.models.RegionSubscription] = response.data diff --git a/src/oci-identity-mcp-server/oracle/oci_identity_mcp_server/tests/test_identity_tools.py b/src/oci-identity-mcp-server/oracle/oci_identity_mcp_server/tests/test_identity_tools.py index f9d0d14d..3d8b3ba3 100644 --- a/src/oci-identity-mcp-server/oracle/oci_identity_mcp_server/tests/test_identity_tools.py +++ b/src/oci-identity-mcp-server/oracle/oci_identity_mcp_server/tests/test_identity_tools.py @@ -17,14 +17,21 @@ from oracle.oci_identity_mcp_server.server import mcp +@pytest.fixture +def mock_identity_client(): + client = MagicMock() + with patch( + "oracle.mcp_common.helpers._create_oci_client", + return_value=client, + ): + yield client + + class TestIdentityTools: @pytest.mark.asyncio - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") @patch("oracle.oci_identity_mcp_server.server.oci.config.from_file") - async def test_list_compartments(self, mock_config_from_file, mock_get_client): + async def test_list_compartments(self, mock_config_from_file, mock_identity_client): mock_config_from_file.return_value = {"tenancy": "test_tenancy"} - mock_client = MagicMock() - mock_get_client.return_value = mock_client mock_list_response = create_autospec(oci.response.Response) mock_get_response = create_autospec(oci.response.Response) @@ -49,8 +56,8 @@ async def test_list_compartments(self, mock_config_from_file, mock_get_client): ) mock_list_response.has_next_page = False mock_list_response.next_page = None - mock_client.list_compartments.return_value = mock_list_response - mock_client.get_compartment.return_value = mock_get_response + mock_identity_client.list_compartments.return_value = mock_list_response + mock_identity_client.get_compartment.return_value = mock_get_response async with Client(mcp) as client: result = ( @@ -68,11 +75,7 @@ async def test_list_compartments(self, mock_config_from_file, mock_get_client): assert result[1]["id"] == "tenancy1" @pytest.mark.asyncio - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") - async def test_list_compartments_without_root(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - + async def test_list_compartments_without_root(self, mock_identity_client): mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = [ oci.identity.models.Compartment( @@ -86,7 +89,7 @@ async def test_list_compartments_without_root(self, mock_get_client): ] mock_list_response.has_next_page = False mock_list_response.next_page = None - mock_client.list_compartments.return_value = mock_list_response + mock_identity_client.list_compartments.return_value = mock_list_response async with Client(mcp) as client: result = ( @@ -105,13 +108,10 @@ async def test_list_compartments_without_root(self, mock_get_client): @pytest.mark.asyncio @patch("oracle.oci_identity_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") async def test_list_compartments_pagination_without_limit( - self, mock_get_client, mock_config_from_file + self, mock_config_from_file, mock_identity_client ): mock_config_from_file.return_value = {"tenancy": "test_tenancy"} - mock_client = MagicMock() - mock_get_client.return_value = mock_client mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.identity.models.Compartment( @@ -158,8 +158,8 @@ async def test_list_compartments_pagination_without_limit( resp2.has_next_page = False resp2.next_page = None - mock_client.list_compartments.side_effect = [resp1, resp2] - mock_client.get_compartment.return_value = mock_get_response + mock_identity_client.list_compartments.side_effect = [resp1, resp2] + mock_identity_client.get_compartment.return_value = mock_get_response async with Client(mcp) as client: result = ( @@ -175,8 +175,8 @@ async def test_list_compartments_pagination_without_limit( assert [r["id"] for r in result] == ["c1", "c2", "c3", "tenancy1"] # Verify pagination call args across pages - first_kwargs = mock_client.list_compartments.call_args_list[0].kwargs - second_kwargs = mock_client.list_compartments.call_args_list[1].kwargs + first_kwargs = mock_identity_client.list_compartments.call_args_list[0].kwargs + second_kwargs = mock_identity_client.list_compartments.call_args_list[1].kwargs assert first_kwargs["page"] is None assert first_kwargs["limit"] is None assert second_kwargs["page"] == "p2" @@ -184,13 +184,10 @@ async def test_list_compartments_pagination_without_limit( @pytest.mark.asyncio @patch("oracle.oci_identity_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") async def test_list_compartments_limit_stops_pagination( - self, mock_get_client, mock_config_from_file + self, mock_config_from_file, mock_identity_client ): mock_config_from_file.return_value = {"tenancy": "test_tenancy"} - mock_client = MagicMock() - mock_get_client.return_value = mock_client mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.identity.models.Compartment( @@ -237,8 +234,8 @@ async def test_list_compartments_limit_stops_pagination( resp2.has_next_page = False resp2.next_page = None - mock_client.list_compartments.side_effect = [resp1, resp2] - mock_client.get_compartment.return_value = mock_get_response + mock_identity_client.list_compartments.side_effect = [resp1, resp2] + mock_identity_client.get_compartment.return_value = mock_get_response limit = 2 async with Client(mcp) as client: @@ -255,17 +252,13 @@ async def test_list_compartments_limit_stops_pagination( assert len(result) == 3 assert [r["id"] for r in result] == ["c1", "c2", "tenancy1"] # With limit, only first page should be fetched - assert mock_client.list_compartments.call_count == 1 - first_kwargs = mock_client.list_compartments.call_args_list[0].kwargs + assert mock_identity_client.list_compartments.call_count == 1 + first_kwargs = mock_identity_client.list_compartments.call_args_list[0].kwargs assert first_kwargs["limit"] == limit assert first_kwargs["page"] is None @pytest.mark.asyncio - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") - async def test_list_availability_domains(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - + async def test_list_availability_domains(self, mock_identity_client): mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = [ oci.identity.models.AvailabilityDomain( @@ -274,7 +267,7 @@ async def test_list_availability_domains(self, mock_get_client): compartment_id="compartment1", ) ] - mock_client.list_availability_domains.return_value = mock_list_response + mock_identity_client.list_availability_domains.return_value = mock_list_response async with Client(mcp) as client: result = ( @@ -290,11 +283,8 @@ async def test_list_availability_domains(self, mock_get_client): assert result[0]["id"] == "ad1" @pytest.mark.asyncio - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") - async def test_list_compartments_exception_propagates(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.list_compartments.side_effect = RuntimeError("boom") + async def test_list_compartments_exception_propagates(self, mock_identity_client): + mock_identity_client.list_compartments.side_effect = RuntimeError("boom") async with Client(mcp) as client: with pytest.raises(ToolError): @@ -306,11 +296,7 @@ async def test_list_compartments_exception_propagates(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") - async def test_get_tenancy(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - + async def test_get_tenancy(self, mock_identity_client): mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.identity.models.Tenancy( id="tenancy1", @@ -318,7 +304,7 @@ async def test_get_tenancy(self, mock_get_client): description="Test tenancy", home_region_key="PHX", ) - mock_client.get_tenancy.return_value = mock_get_response + mock_identity_client.get_tenancy.return_value = mock_get_response async with Client(mcp) as client: call_tool_result = await client.call_tool( @@ -332,12 +318,11 @@ async def test_get_tenancy(self, mock_get_client): assert result["id"] == "tenancy1" @pytest.mark.asyncio - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") @patch("oracle.oci_identity_mcp_server.server.oci.config.from_file") - async def test_get_current_tenancy(self, mock_config_from_file, mock_get_client): + async def test_get_current_tenancy( + self, mock_config_from_file, mock_identity_client + ): mock_config_from_file.return_value = {"tenancy": "test_tenancy"} - mock_client = MagicMock() - mock_get_client.return_value = mock_client mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.identity.models.Tenancy( @@ -346,7 +331,7 @@ async def test_get_current_tenancy(self, mock_config_from_file, mock_get_client) description="Test tenancy", home_region_key="PHX", ) - mock_client.get_tenancy.return_value = mock_get_response + mock_identity_client.get_tenancy.return_value = mock_get_response async with Client(mcp) as client: call_tool_result = await client.call_tool( @@ -358,16 +343,12 @@ async def test_get_current_tenancy(self, mock_config_from_file, mock_get_client) assert result["id"] == "tenancy1" @pytest.mark.asyncio - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") - async def test_create_auth_token(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - + async def test_create_auth_token(self, mock_identity_client): mock_create_response = create_autospec(oci.response.Response) mock_create_response.data = oci.identity.models.AuthToken( token="token1", description="Test token", lifecycle_state="ACTIVE" ) - mock_client.create_auth_token.return_value = mock_create_response + mock_identity_client.create_auth_token.return_value = mock_create_response async with Client(mcp) as client: call_tool_result = await client.call_tool( @@ -381,20 +362,17 @@ async def test_create_auth_token(self, mock_get_client): assert result["token"] == "token1" @pytest.mark.asyncio - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") @patch("oracle.oci_identity_mcp_server.server.oci.config.from_file") async def test_get_current_user_from_config_user( - self, mock_config_from_file, mock_get_client + self, mock_config_from_file, mock_identity_client ): mock_config_from_file.return_value = {"user": "test_user"} - mock_client = MagicMock() - mock_get_client.return_value = mock_client mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.identity.models.User( id="user1", name="User 1", description="Test user" ) - mock_client.get_user.return_value = mock_get_response + mock_identity_client.get_user.return_value = mock_get_response async with Client(mcp) as client: call_tool_result = await client.call_tool( @@ -419,11 +397,10 @@ def _make_jwt(self, payload_dict: dict) -> str: return f"{header_b64}.{payload_b64}.signature" @pytest.mark.asyncio - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") @patch("oracle.oci_identity_mcp_server.server.os.path.exists") @patch("oracle.oci_identity_mcp_server.server.oci.config.from_file") async def test_get_current_user_fallback_from_token_sub( - self, mock_config_from_file, mock_path_exists, mock_get_client + self, mock_config_from_file, mock_path_exists, mock_identity_client ): # No user in config, but provide a token file with JWT 'sub' token = self._make_jwt({"sub": "ocid1.user.oc1..sub"}) @@ -433,12 +410,9 @@ async def test_get_current_user_fallback_from_token_sub( mock_path_exists.return_value = True m = mock_open(read_data=token) - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_resp = create_autospec(oci.response.Response) mock_resp.data = oci.identity.models.User(id="user-sub", name="User From Sub") - mock_client.get_user.return_value = mock_resp + mock_identity_client.get_user.return_value = mock_resp with patch("builtins.open", m): async with Client(mcp) as client: @@ -447,14 +421,15 @@ async def test_get_current_user_fallback_from_token_sub( ).structured_content assert result["id"] == "user-sub" # Ensure client.get_user called with derived OCID - mock_client.get_user.assert_called_once_with("ocid1.user.oc1..sub") + mock_identity_client.get_user.assert_called_once_with( + "ocid1.user.oc1..sub" + ) @pytest.mark.asyncio - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") @patch("oracle.oci_identity_mcp_server.server.os.path.exists") @patch("oracle.oci_identity_mcp_server.server.oci.config.from_file") async def test_get_current_user_fallback_from_token_opc_user_id( - self, mock_config_from_file, mock_path_exists, mock_get_client + self, mock_config_from_file, mock_path_exists, mock_identity_client ): token = self._make_jwt({"opc-user-id": "ocid1.user.oc1..opc"}) mock_config_from_file.return_value = { @@ -463,12 +438,9 @@ async def test_get_current_user_fallback_from_token_opc_user_id( mock_path_exists.return_value = True m = mock_open(read_data=token) - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_resp = create_autospec(oci.response.Response) mock_resp.data = oci.identity.models.User(id="user-opc", name="User From OPC") - mock_client.get_user.return_value = mock_resp + mock_identity_client.get_user.return_value = mock_resp with patch("builtins.open", m): async with Client(mcp) as client: @@ -476,14 +448,15 @@ async def test_get_current_user_fallback_from_token_opc_user_id( await client.call_tool("get_current_user", {}) ).structured_content assert result["id"] == "user-opc" - mock_client.get_user.assert_called_once_with("ocid1.user.oc1..opc") + mock_identity_client.get_user.assert_called_once_with( + "ocid1.user.oc1..opc" + ) @pytest.mark.asyncio - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") @patch("oracle.oci_identity_mcp_server.server.os.path.exists") @patch("oracle.oci_identity_mcp_server.server.oci.config.from_file") async def test_get_current_user_raises_when_no_user( - self, mock_config_from_file, mock_path_exists, mock_get_client + self, mock_config_from_file, mock_path_exists, mock_identity_client ): # No user in config, token file missing -> KeyError should propagate as ToolError mock_config_from_file.return_value = {"security_token_file": "/tmp/token.txt"} @@ -494,15 +467,11 @@ async def test_get_current_user_raises_when_no_user( await client.call_tool("get_current_user", {}) @pytest.mark.asyncio - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") - async def test_get_compartment_by_name(self, mock_get_client): + async def test_get_compartment_by_name(self, mock_identity_client): """ Tests finding a compartment by name, including simulating pagination where the target is on the second page. """ - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_response_p1 = create_autospec(oci.response.Response) mock_response_p1.data = [ oci.identity.models.Compartment(name="WrongName", id="wrong_id") @@ -522,7 +491,10 @@ async def test_get_compartment_by_name(self, mock_get_client): mock_response_p2.has_next_page = False mock_response_p2.next_page = None - mock_client.list_compartments.side_effect = [mock_response_p1, mock_response_p2] + mock_identity_client.list_compartments.side_effect = [ + mock_response_p1, + mock_response_p2, + ] async with Client(mcp) as client: raw_content = ( @@ -543,19 +515,15 @@ async def test_get_compartment_by_name(self, mock_get_client): assert result["id"] == "target_id" assert result["name"] == "TargetComp" - assert mock_client.list_compartments.call_count == 2 + assert mock_identity_client.list_compartments.call_count == 2 @pytest.mark.asyncio - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") - async def test_get_compartment_by_name_not_found(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - + async def test_get_compartment_by_name_not_found(self, mock_identity_client): resp = create_autospec(oci.response.Response) resp.data = [] resp.has_next_page = False resp.next_page = None - mock_client.list_compartments.return_value = resp + mock_identity_client.list_compartments.return_value = resp async with Client(mcp) as client: raw_content = ( @@ -573,11 +541,7 @@ async def test_get_compartment_by_name_not_found(self, mock_get_client): assert result is None @pytest.mark.asyncio - @patch("oracle.oci_identity_mcp_server.server.get_identity_client") - async def test_list_subscribed_regions(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - + async def test_list_subscribed_regions(self, mock_identity_client): mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = [ oci.identity.models.RegionSubscription( @@ -593,7 +557,7 @@ async def test_list_subscribed_regions(self, mock_get_client): is_home_region=False, ), ] - mock_client.list_region_subscriptions.return_value = mock_list_response + mock_identity_client.list_region_subscriptions.return_value = mock_list_response async with Client(mcp) as client: result = ( @@ -661,12 +625,11 @@ def test_main_with_only_port(self, mock_getenv, mock_mcp_run): @pytest.mark.asyncio -@patch("oracle.oci_identity_mcp_server.server.get_identity_client") @patch("oracle.oci_identity_mcp_server.server.oci.config.from_file") -async def test_get_current_tenancy_with_env_override(mock_from_file, mock_get_client): +async def test_get_current_tenancy_with_env_override( + mock_from_file, mock_identity_client +): mock_from_file.return_value = {"tenancy": "base-tenancy"} - mock_client = MagicMock() - mock_get_client.return_value = mock_client mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.identity.models.Tenancy( @@ -675,7 +638,7 @@ async def test_get_current_tenancy_with_env_override(mock_from_file, mock_get_cl description="Test tenancy", home_region_key="PHX", ) - mock_client.get_tenancy.return_value = mock_get_response + mock_identity_client.get_tenancy.return_value = mock_get_response with patch.dict( os.environ, {"TENANCY_ID_OVERRIDE": "ocid1.tenancy.oc1..override"}, clear=False @@ -686,15 +649,14 @@ async def test_get_current_tenancy_with_env_override(mock_from_file, mock_get_cl ).structured_content assert result["id"] == "tenancy1" - mock_client.get_tenancy.assert_called_once_with("ocid1.tenancy.oc1..override") + mock_identity_client.get_tenancy.assert_called_once_with( + "ocid1.tenancy.oc1..override" + ) @pytest.mark.asyncio -@patch("oracle.oci_identity_mcp_server.server.get_identity_client") -async def test_get_tenancy_exception_propagates(mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.get_tenancy.side_effect = RuntimeError("boom") +async def test_get_tenancy_exception_propagates(mock_identity_client): + mock_identity_client.get_tenancy.side_effect = RuntimeError("boom") async with Client(mcp) as client: with pytest.raises(ToolError): @@ -702,11 +664,8 @@ async def test_get_tenancy_exception_propagates(mock_get_client): @pytest.mark.asyncio -@patch("oracle.oci_identity_mcp_server.server.get_identity_client") -async def test_list_availability_domains_exception_propagates(mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.list_availability_domains.side_effect = ValueError("err") +async def test_list_availability_domains_exception_propagates(mock_identity_client): + mock_identity_client.list_availability_domains.side_effect = ValueError("err") async with Client(mcp) as client: with pytest.raises(ToolError): @@ -716,11 +675,10 @@ async def test_list_availability_domains_exception_propagates(mock_get_client): @pytest.mark.asyncio -@patch("oracle.oci_identity_mcp_server.server.get_identity_client") @patch("oracle.oci_identity_mcp_server.server.os.path.exists") @patch("oracle.oci_identity_mcp_server.server.oci.config.from_file") async def test_get_current_user_invalid_token_decode_raises( - mock_from_file, mock_exists, mock_get_client + mock_from_file, mock_exists, mock_identity_client ): mock_from_file.return_value = {"security_token_file": "/tmp/token.txt"} mock_exists.return_value = True @@ -731,112 +689,3 @@ async def test_get_current_user_invalid_token_decode_raises( async with Client(mcp) as client: with pytest.raises(ToolError): await client.call_tool("get_current_user", {}) - - -class TestGetClient: - @patch("oracle.oci_identity_mcp_server.server.oci.identity.IdentityClient") - @patch("oracle.oci_identity_mcp_server.server.oci.auth.signers.SecurityTokenSigner") - @patch( - "oracle.oci_identity_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_identity_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_identity_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_identity_mcp_server.server.os.getenv") - def test_get_identity_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default - ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - - # Act - result = server.get_identity_client() - - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - expected_user_agent = f"{server.__project__.split('oracle.', 1)[1].split('-server', 1)[0]}/{server.__version__}" # noqa - assert passed_config.get("additional_user_agent") == expected_user_agent - # And we returned the client instance - assert result == mock_client.return_value - - @patch("oracle.oci_identity_mcp_server.server.oci.identity.IdentityClient") - @patch("oracle.oci_identity_mcp_server.server.oci.auth.signers.SecurityTokenSigner") - @patch( - "oracle.oci_identity_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_identity_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_identity_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_identity_mcp_server.server.os.getenv") - def test_get_identity_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_identity_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - assert "additional_user_agent" in config - assert ( - isinstance(config["additional_user_agent"], str) - and "/" in config["additional_user_agent"] - ) - # Returned object is client instance - assert srv_client is mock_client.return_value diff --git a/src/oci-identity-mcp-server/pyproject.toml b/src/oci-identity-mcp-server/pyproject.toml index 77f9ba32..eac1f3f4 100644 --- a/src/oci-identity-mcp-server/pyproject.toml +++ b/src/oci-identity-mcp-server/pyproject.toml @@ -53,3 +53,9 @@ omit = [ ] precision = 2 fail_under = 90 + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] From 9f35f214734f7f362bbc5beaf638881ff1ec9cd7 Mon Sep 17 00:00:00 2001 From: Chris Boffa Date: Fri, 13 Feb 2026 12:42:32 -0500 Subject: [PATCH 2/2] enable decorator on other projects --- src/oci-api-mcp-server/pyproject.toml | 7 + .../oci_cloud_guard_mcp_server/server.py | 43 +- .../tests/test_cloud_guard_tools.py | 303 ++++++------ src/oci-cloud-guard-mcp-server/pyproject.toml | 7 + src/oci-cloud-mcp-server/pyproject.toml | 7 + .../server.py | 31 +- .../test_compute_instance_agent_tools.py | 161 +------ .../pyproject.toml | 7 + .../oracle/oci_compute_mcp_server/server.py | 81 ++-- .../tests/test_compute_tools.py | 234 +++------- src/oci-compute-mcp-server/pyproject.toml | 7 + src/oci-database-mcp-server/pyproject.toml | 7 + .../oracle/oci_faaas_mcp_server/server.py | 42 +- .../tests/test_faaas_tools.py | 130 +----- src/oci-faaas-mcp-server/pyproject.toml | 7 + .../oracle/oci_logging_mcp_server/server.py | 58 +-- .../tests/test_logging_tools.py | 274 ++--------- src/oci-logging-mcp-server/pyproject.toml | 7 + .../oracle/oci_migration_mcp_server/server.py | 44 +- .../tests/test_migration_tools.py | 215 +-------- src/oci-migration-mcp-server/pyproject.toml | 7 + .../oci_monitoring_mcp_server/server.py | 45 +- .../tests/test_monitoring_tools.py | 229 ++------- src/oci-monitoring-mcp-server/pyproject.toml | 7 + .../server.py | 110 +++-- .../tests/test_network_load_balancer_tools.py | 435 ++++++------------ .../pyproject.toml | 7 + .../oci_networking_mcp_server/server.py | 155 ++++--- .../tests/test_networking_tools.py | 366 +++++---------- src/oci-networking-mcp-server/pyproject.toml | 5 + .../oci_object_storage_mcp_server/server.py | 83 ++-- .../tests/test_object_storage_tools.py | 318 +++---------- .../pyproject.toml | 7 + src/oci-pricing-mcp-server/pyproject.toml | 7 + .../oracle/oci_registry_mcp_server/server.py | 45 +- .../tests/test_registry_tools.py | 160 +------ src/oci-registry-mcp-server/pyproject.toml | 6 + .../oci_resource_search_mcp_server/server.py | 49 +- .../tests/test_resource_search_tools.py | 203 +------- .../pyproject.toml | 6 + .../oracle/oci_usage_mcp_server/server.py | 28 +- .../tests/test_usage_tools.py | 113 +---- src/oci-usage-mcp-server/pyproject.toml | 6 + 43 files changed, 1156 insertions(+), 2913 deletions(-) diff --git a/src/oci-api-mcp-server/pyproject.toml b/src/oci-api-mcp-server/pyproject.toml index eb0eac8e..5aa9701e 100644 --- a/src/oci-api-mcp-server/pyproject.toml +++ b/src/oci-api-mcp-server/pyproject.toml @@ -53,3 +53,10 @@ omit = [ ] precision = 2 fail_under = 90 + + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-cloud-guard-mcp-server/oracle/oci_cloud_guard_mcp_server/server.py b/src/oci-cloud-guard-mcp-server/oracle/oci_cloud_guard_mcp_server/server.py index 86bf9bcc..f5a5b1c7 100644 --- a/src/oci-cloud-guard-mcp-server/oracle/oci_cloud_guard_mcp_server/server.py +++ b/src/oci-cloud-guard-mcp-server/oracle/oci_cloud_guard_mcp_server/server.py @@ -12,41 +12,25 @@ import oci from fastmcp import FastMCP from oci.cloud_guard import CloudGuardClient +from oracle.mcp_common import with_oci_client from oracle.oci_cloud_guard_mcp_server.models import ( Problem, map_problem, ) from pydantic import Field -from . import __project__, __version__ +from . import __project__ logger = Logger(__name__, level="INFO") mcp = FastMCP(name=__project__) -def get_cloud_guard_client(): - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - - user_agent_name = __project__.split("oracle.", 1)[1].split("-server", 1)[0] - config["additional_user_agent"] = f"{user_agent_name}/{__version__}" - - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = os.path.expanduser(config["security_token_file"]) - token = None - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - return CloudGuardClient(config, signer=signer) - - @mcp.tool( name="list_problems", description="Returns a list of all Problems identified by Cloud Guard.", ) +@with_oci_client(CloudGuardClient) def list_problems( compartment_id: str = Field(..., description="The OCID of the compartment"), risk_level: Optional[str] = Field(None, description="Risk level of the problem"), @@ -65,10 +49,11 @@ def list_problems( 30, description="Number of days to look back for problems" ), limit: Optional[int] = Field(10, description="The number of problems to return"), + *, + client: CloudGuardClient, ) -> list[Problem]: - time_filter = ( - datetime.now(timezone.utc) - timedelta(days=time_range_days) - ).isoformat() + days = time_range_days if time_range_days is not None else 30 + time_filter = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() kwargs = { "compartment_id": compartment_id, @@ -83,7 +68,7 @@ def list_problems( if detector_rule_ids: kwargs["detector_rule_id_list"] = detector_rule_ids - response = get_cloud_guard_client().list_problems(**kwargs) + response = client.list_problems(**kwargs) problems: list[Problem] = [] data: list[oci.cloud_guard.models.Problem] = response.data.items @@ -97,10 +82,13 @@ def list_problems( name="get_problem_details", description="Get the details for a Problem identified by problemId.", ) +@with_oci_client(CloudGuardClient) def get_problem_details( - problem_id: str = Field(..., description="The OCID of the problem") + problem_id: str = Field(..., description="The OCID of the problem"), + *, + client: CloudGuardClient, ) -> Problem: - response = get_cloud_guard_client().get_problem(problem_id=problem_id) + response = client.get_problem(problem_id=problem_id) problem = response.data return map_problem(problem) @@ -110,6 +98,7 @@ def get_problem_details( description="Changes the current status of the problem, identified by problemId, to the status " "specified in the UpdateProblemStatusDetails resource that you pass.", ) +@with_oci_client(CloudGuardClient) def update_problem_status( problem_id: str = Field(..., description="The OCID of the problem"), status: Literal[ @@ -119,11 +108,13 @@ def update_problem_status( description="Action taken by user. Allowed values are: OPEN, RESOLVED, DISMISSED, CLOSED", ), comment: str = Field(None, description="A comment from the user"), + *, + client: CloudGuardClient, ) -> Problem: updated_problem_status = oci.cloud_guard.models.UpdateProblemStatusDetails( status=status, comment=comment ) - response = get_cloud_guard_client().update_problem_status( + response = client.update_problem_status( problem_id=problem_id, update_problem_status_details=updated_problem_status, ) diff --git a/src/oci-cloud-guard-mcp-server/oracle/oci_cloud_guard_mcp_server/tests/test_cloud_guard_tools.py b/src/oci-cloud-guard-mcp-server/oracle/oci_cloud_guard_mcp_server/tests/test_cloud_guard_tools.py index e3fa177f..88371755 100644 --- a/src/oci-cloud-guard-mcp-server/oracle/oci_cloud_guard_mcp_server/tests/test_cloud_guard_tools.py +++ b/src/oci-cloud-guard-mcp-server/oracle/oci_cloud_guard_mcp_server/tests/test_cloud_guard_tools.py @@ -5,7 +5,9 @@ """ # noinspection PyPackageRequirements -from unittest.mock import MagicMock, create_autospec, mock_open, patch +import os +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, create_autospec, patch import oci import oracle.oci_cloud_guard_mcp_server.server as server @@ -15,7 +17,7 @@ class TestResourceSearchTools: @pytest.mark.asyncio - @patch("oracle.oci_cloud_guard_mcp_server.server.get_cloud_guard_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_all_problems(self, mock_get_client): resource_id = "ocid.resource1" mock_client = MagicMock() @@ -46,7 +48,79 @@ async def test_list_all_problems(self, mock_get_client): assert result[0]["id"] == resource_id @pytest.mark.asyncio - @patch("oracle.oci_cloud_guard_mcp_server.server.get_cloud_guard_client") + @patch("oracle.mcp_common.helpers._create_oci_client") + @patch("oracle.oci_cloud_guard_mcp_server.server.datetime") + async def test_list_problems_applies_optional_filters( + self, mock_datetime, mock_get_client + ): + fake_now = datetime(2025, 1, 31, 12, 0, tzinfo=timezone.utc) + mock_datetime.now.return_value = fake_now + + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_response = create_autospec(oci.response.Response) + mock_response.data = oci.cloud_guard.models.ProblemCollection(items=[]) + mock_client.list_problems.return_value = mock_response + + args = { + "compartment_id": "ocid.compartment", + "risk_level": "HIGH", + "lifecycle_state": "INACTIVE", + "detector_rule_ids": ["det-1", "det-2"], + "time_range_days": 5, + "limit": 5, + } + + async with Client(server.mcp) as client: + result = (await client.call_tool("list_problems", args)).structured_content[ + "result" + ] + + assert result == [] + kwargs = mock_client.list_problems.call_args.kwargs + expected_time = (fake_now - timedelta(days=5)).isoformat() + assert kwargs["time_last_detected_greater_than_or_equal_to"] == expected_time + assert kwargs["risk_level"] == "HIGH" + assert kwargs["lifecycle_state"] == "INACTIVE" + assert kwargs["detector_rule_id_list"] == ["det-1", "det-2"] + assert kwargs["limit"] == 5 + + @pytest.mark.asyncio + @patch("oracle.mcp_common.helpers._create_oci_client") + @patch("oracle.oci_cloud_guard_mcp_server.server.datetime") + async def test_list_problems_omits_filters_when_none( + self, mock_datetime, mock_get_client + ): + fake_now = datetime(2025, 2, 1, 12, 0, tzinfo=timezone.utc) + mock_datetime.now.return_value = fake_now + + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_response = create_autospec(oci.response.Response) + mock_response.data = oci.cloud_guard.models.ProblemCollection(items=[]) + mock_client.list_problems.return_value = mock_response + + async with Client(server.mcp) as client: + await client.call_tool( + "list_problems", + { + "compartment_id": "ocid.compartment", + "risk_level": None, + "lifecycle_state": None, + "detector_rule_ids": None, + "time_range_days": None, + }, + ) + + kwargs = mock_client.list_problems.call_args.kwargs + expected_time = (fake_now - timedelta(days=30)).isoformat() + assert kwargs["time_last_detected_greater_than_or_equal_to"] == expected_time + assert "risk_level" not in kwargs + assert "lifecycle_state" not in kwargs + assert "detector_rule_id_list" not in kwargs + + @pytest.mark.asyncio + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_get_problem_details(self, mock_get_client): problem_id = "ocid.resource1" mock_client = MagicMock() @@ -80,7 +154,54 @@ async def test_get_problem_details(self, mock_get_client): assert result["id"] == problem_id @pytest.mark.asyncio - @patch("oracle.oci_cloud_guard_mcp_server.server.get_cloud_guard_client") + @patch( + "oracle.oci_cloud_guard_mcp_server.server.oci.cloud_guard.models.UpdateProblemStatusDetails" + ) + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_update_problem_status_uses_sdk_model( + self, mock_get_client, mock_update_model + ): + problem_id = "ocid.resource1" + status = "RESOLVED" + comment = "handled" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + + mock_update_model_instance = MagicMock() + mock_update_model.return_value = mock_update_model_instance + + mock_update_problem_status_response = create_autospec(oci.response.Response) + mock_update_problem_status_response.data = oci.cloud_guard.models.Problem( + id=problem_id, + lifecycle_detail=status, + comment=comment, + ) + mock_client.update_problem_status.return_value = ( + mock_update_problem_status_response + ) + + async with Client(server.mcp) as client: + result = ( + await client.call_tool( + "update_problem_status", + { + "problem_id": problem_id, + "status": status, + "comment": comment, + }, + ) + ).structured_content + + mock_update_model.assert_called_once_with(status=status, comment=comment) + mock_client.update_problem_status.assert_called_once_with( + problem_id=problem_id, + update_problem_status_details=mock_update_model_instance, + ) + assert result["lifecycle_detail"] == status + assert result["comment"] == comment + + @pytest.mark.asyncio + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_update_problem_status(self, mock_get_client): problem_id = "ocid.resource1" status = "OPEN" @@ -114,164 +235,22 @@ async def test_update_problem_status(self, mock_get_client): assert result["lifecycle_detail"] == status assert result["comment"] == comment - -class TestServer: - @patch("oracle.oci_cloud_guard_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_host_and_port(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_HOST": "1.2.3.4", - "ORACLE_MCP_PORT": "8888", - } - - mock_getenv.side_effect = lambda x: mock_env.get(x) - - server.main() - mock_mcp_run.assert_called_once_with( - transport="http", - host=mock_env["ORACLE_MCP_HOST"], - port=int(mock_env["ORACLE_MCP_PORT"]), - ) - - @patch("oracle.oci_cloud_guard_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_without_host_and_port(self, mock_getenv, mock_mcp_run): - mock_getenv.return_value = None - - server.main() - mock_mcp_run.assert_called_once_with() - - @patch("oracle.oci_cloud_guard_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_only_host(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_HOST": "1.2.3.4", - } - mock_getenv.side_effect = lambda x: mock_env.get(x) - - server.main() - mock_mcp_run.assert_called_once_with() - - @patch("oracle.oci_cloud_guard_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_only_port(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_PORT": "8888", - } - mock_getenv.side_effect = lambda x: mock_env.get(x) - - server.main() - mock_mcp_run.assert_called_once_with() - - -class TestGetClient: - @patch("oracle.oci_cloud_guard_mcp_server.server.CloudGuardClient") - @patch( - "oracle.oci_cloud_guard_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_cloud_guard_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_cloud_guard_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_cloud_guard_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_cloud_guard_mcp_server.server.os.getenv") - def test_get_cloud_guard_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default + @patch.object(server, "mcp") + def test_main_uses_http_transport_when_env_configured(self, mock_mcp): + with patch.dict( + os.environ, + {"ORACLE_MCP_HOST": "127.0.0.1", "ORACLE_MCP_PORT": "8080"}, + clear=False, + ): + server.main() + + mock_mcp.run.assert_called_once_with( + transport="http", host="127.0.0.1", port=8080 ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - # Act - result = server.get_cloud_guard_client() + @patch.object(server, "mcp") + def test_main_uses_default_transport_without_env(self, mock_mcp): + with patch.dict(os.environ, {}, clear=True): + server.main() - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - expected_user_agent = f"{server.__project__.split('oracle.', 1)[1].split('-server', 1)[0]}/{server.__version__}" # noqa - assert passed_config.get("additional_user_agent") == expected_user_agent - # And we returned the client instance - assert result == mock_client.return_value - - @patch("oracle.oci_cloud_guard_mcp_server.server.CloudGuardClient") - @patch( - "oracle.oci_cloud_guard_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_cloud_guard_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_cloud_guard_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_cloud_guard_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_cloud_guard_mcp_server.server.os.getenv") - def test_get_cloud_guard_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_cloud_guard_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - assert "additional_user_agent" in config - assert ( - isinstance(config["additional_user_agent"], str) - and "/" in config["additional_user_agent"] - ) - # Returned object is client instance - assert srv_client is mock_client.return_value + mock_mcp.run.assert_called_once_with() diff --git a/src/oci-cloud-guard-mcp-server/pyproject.toml b/src/oci-cloud-guard-mcp-server/pyproject.toml index 5f25d44b..f302bbba 100644 --- a/src/oci-cloud-guard-mcp-server/pyproject.toml +++ b/src/oci-cloud-guard-mcp-server/pyproject.toml @@ -54,3 +54,10 @@ omit = [ ] precision = 2 fail_under = 90 + + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-cloud-mcp-server/pyproject.toml b/src/oci-cloud-mcp-server/pyproject.toml index face7e1d..c06ad3b6 100644 --- a/src/oci-cloud-mcp-server/pyproject.toml +++ b/src/oci-cloud-mcp-server/pyproject.toml @@ -53,3 +53,10 @@ omit = [ ] precision = 2 fail_under = 90 + + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-compute-instance-agent-mcp-server/oracle/oci_compute_instance_agent_mcp_server/server.py b/src/oci-compute-instance-agent-mcp-server/oracle/oci_compute_instance_agent_mcp_server/server.py index 4c744e44..00e008eb 100644 --- a/src/oci-compute-instance-agent-mcp-server/oracle/oci_compute_instance_agent_mcp_server/server.py +++ b/src/oci-compute-instance-agent-mcp-server/oracle/oci_compute_instance_agent_mcp_server/server.py @@ -18,6 +18,7 @@ InstanceAgentCommandSourceViaTextDetails, InstanceAgentCommandTarget, ) +from oracle.mcp_common import with_oci_client from oracle.oci_compute_instance_agent_mcp_server.models import ( InstanceAgentCommandExecution, InstanceAgentCommandExecutionSummary, @@ -26,35 +27,17 @@ ) from pydantic import Field -from . import __project__, __version__ +from . import __project__ logger = Logger(__name__, level="INFO") mcp = FastMCP(name=__project__) -def get_compute_instance_agent_client(): - logger.info("entering get_compute_instance_agent_client") - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - - user_agent_name = __project__.split("oracle.", 1)[1].split("-server", 1)[0] - config["additional_user_agent"] = f"{user_agent_name}/{__version__}" - - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = os.path.expanduser(config["security_token_file"]) - token = None - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - return oci.compute_instance_agent.ComputeInstanceAgentClient(config, signer=signer) - - @mcp.tool( description="Runs a script on a compute instance", ) +@with_oci_client(oci.compute_instance_agent.ComputeInstanceAgentClient) def run_instance_agent_command( compartment_id: str = Field( ..., description="The OCID of the compartment to create the command in" @@ -73,9 +56,10 @@ def run_instance_agent_command( execution_time_out_in_seconds: Optional[int] = Field( 30, description="The command's timeout in seconds" ), + *, + client: oci.compute_instance_agent.ComputeInstanceAgentClient, ) -> InstanceAgentCommandExecution: try: - client = get_compute_instance_agent_client() command_details = CreateInstanceAgentCommandDetails( display_name=display_name, compartment_id=compartment_id, @@ -126,6 +110,7 @@ def run_instance_agent_command( @mcp.tool(description="Lists an instance's agent command executions") +@with_oci_client(oci.compute_instance_agent.ComputeInstanceAgentClient) def list_instance_agent_command_executions( compartment_id: str = Field( ..., description="The OCID of the compartment to list commands from" @@ -138,12 +123,12 @@ def list_instance_agent_command_executions( description="The maximum amount of commands to return. If None, there is no limit.", ge=1, ), + *, + client: oci.compute_instance_agent.ComputeInstanceAgentClient, ) -> list[InstanceAgentCommandExecutionSummary]: commands: list[InstanceAgentCommandExecutionSummary] = [] try: - client = get_compute_instance_agent_client() - response: oci.response.Response = None has_next_page = True next_page: str = None diff --git a/src/oci-compute-instance-agent-mcp-server/oracle/oci_compute_instance_agent_mcp_server/tests/test_compute_instance_agent_tools.py b/src/oci-compute-instance-agent-mcp-server/oracle/oci_compute_instance_agent_mcp_server/tests/test_compute_instance_agent_tools.py index 6cefff8c..bbbb96f5 100644 --- a/src/oci-compute-instance-agent-mcp-server/oracle/oci_compute_instance_agent_mcp_server/tests/test_compute_instance_agent_tools.py +++ b/src/oci-compute-instance-agent-mcp-server/oracle/oci_compute_instance_agent_mcp_server/tests/test_compute_instance_agent_tools.py @@ -4,7 +4,7 @@ https://oss.oracle.com/licenses/upl. """ -from unittest.mock import MagicMock, create_autospec, mock_open, patch +from unittest.mock import MagicMock, create_autospec, patch import oci import oracle.oci_compute_instance_agent_mcp_server.server as server @@ -26,10 +26,10 @@ class TestComputeInstanceAgent: @pytest.mark.asyncio @patch("oci.wait_until") - @patch( - "oracle.oci_compute_instance_agent_mcp_server.server.get_compute_instance_agent_client" - ) - async def test_run_instance_agent_command(self, mock_get_client, mock_wait_until): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_run_instance_agent_command( + self, mock_create_client, mock_wait_until + ): compartment_id = "test_compartment" instance_id = "test_instance" display_name = "test_command" @@ -37,7 +37,7 @@ async def test_run_instance_agent_command(self, mock_get_client, mock_wait_until execution_time_out_in_seconds = 30 mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_create_response = create_autospec(oci.response.Response) mock_create_response.data = InstanceAgentCommand( id="command1", @@ -107,15 +107,13 @@ async def test_run_instance_agent_command(self, mock_get_client, mock_wait_until ) @pytest.mark.asyncio - @patch( - "oracle.oci_compute_instance_agent_mcp_server.server.get_compute_instance_agent_client" - ) - async def test_list_instance_agent_commands(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_instance_agent_commands(self, mock_create_client): compartment_id = "test_compartment" instance_id = "test_instance" mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_list_response = create_autospec(oci.response.Response) mock_command_1 = InstanceAgentCommandExecutionSummary( instance_agent_command_id="command1", @@ -154,11 +152,9 @@ async def test_list_instance_agent_commands(self, mock_get_client): ) @pytest.mark.asyncio - @patch( - "oracle.oci_compute_instance_agent_mcp_server.server.get_compute_instance_agent_client" - ) + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_instance_agent_commands_pagination_and_limit_and_output_types( - self, mock_get_client + self, mock_create_client ): # This test exercises: # - pagination (has_next_page + next_page) @@ -168,7 +164,7 @@ async def test_list_instance_agent_commands_pagination_and_limit_and_output_type instance_id = "ocid1.instance" mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client # First page with TEXT output content summary resp_page_1 = create_autospec(oci.response.Response) @@ -287,14 +283,12 @@ async def test_list_instance_agent_commands_pagination_and_limit_and_output_type assert second_kwargs["limit"] == limit @pytest.mark.asyncio - @patch( - "oracle.oci_compute_instance_agent_mcp_server.server.get_compute_instance_agent_client" - ) + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_run_instance_agent_command_exception_propagates( - self, mock_get_client + self, mock_create_client ): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_client.create_instance_agent_command.side_effect = RuntimeError("boom") async with Client(mcp) as client: @@ -360,15 +354,13 @@ def test_main_with_only_port(self, mock_getenv, mock_mcp_run): mock_mcp_run.assert_called_once_with() -@patch( - "oracle.oci_compute_instance_agent_mcp_server.server.get_compute_instance_agent_client" -) +@patch("oracle.mcp_common.helpers._create_oci_client") @pytest.mark.asyncio async def test_list_instance_agent_command_executions_exception_propagates( - mock_get_client, + mock_create_client, ): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_client.list_instance_agent_command_executions.side_effect = ValueError("err") async with Client(mcp) as client: @@ -381,120 +373,3 @@ async def test_list_instance_agent_command_executions_exception_propagates( "limit": 1, }, ) - - -class TestGetClient: - @patch( - "oracle.oci_compute_instance_agent_mcp_server.server.oci.compute_instance_agent.ComputeInstanceAgentClient" # noqa - ) - @patch( - "oracle.oci_compute_instance_agent_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_compute_instance_agent_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_compute_instance_agent_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_compute_instance_agent_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_compute_instance_agent_mcp_server.server.os.getenv") - def test_get_compute_instance_agent_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default - ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - - # Act - result = server.get_compute_instance_agent_client() - - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - expected_user_agent = f"{server.__project__.split('oracle.', 1)[1].split('-server', 1)[0]}/{server.__version__}" # noqa - assert passed_config.get("additional_user_agent") == expected_user_agent - # And we returned the client instance - assert result == mock_client.return_value - - @patch( - "oracle.oci_compute_instance_agent_mcp_server.server.oci.compute_instance_agent.ComputeInstanceAgentClient" # noqa - ) - @patch( - "oracle.oci_compute_instance_agent_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_compute_instance_agent_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_compute_instance_agent_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_compute_instance_agent_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_compute_instance_agent_mcp_server.server.os.getenv") - def test_get_compute_instance_agent_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_compute_instance_agent_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - assert "additional_user_agent" in config - assert ( - isinstance(config["additional_user_agent"], str) - and "/" in config["additional_user_agent"] - ) - # Returned object is client instance - assert srv_client is mock_client.return_value diff --git a/src/oci-compute-instance-agent-mcp-server/pyproject.toml b/src/oci-compute-instance-agent-mcp-server/pyproject.toml index d55c61e9..51a8dcbb 100644 --- a/src/oci-compute-instance-agent-mcp-server/pyproject.toml +++ b/src/oci-compute-instance-agent-mcp-server/pyproject.toml @@ -53,3 +53,10 @@ omit = [ ] precision = 2 fail_under = 90 + + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-compute-mcp-server/oracle/oci_compute_mcp_server/server.py b/src/oci-compute-mcp-server/oracle/oci_compute_mcp_server/server.py index 462657d7..3a242a95 100644 --- a/src/oci-compute-mcp-server/oracle/oci_compute_mcp_server/server.py +++ b/src/oci-compute-mcp-server/oracle/oci_compute_mcp_server/server.py @@ -10,6 +10,7 @@ import oci from fastmcp import FastMCP +from oracle.mcp_common import with_oci_client from oracle.oci_compute_mcp_server.consts import ( DEFAULT_MEMORY_IN_GBS, DEFAULT_OCPU_COUNT, @@ -28,32 +29,15 @@ ) from pydantic import Field -from . import __project__, __version__ +from . import __project__ logger = Logger(__name__, level="INFO") mcp = FastMCP(name=__project__) -def get_compute_client(): - logger.info("entering get_compute_client") - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - user_agent_name = __project__.split("oracle.", 1)[1].split("-server", 1)[0] - config["additional_user_agent"] = f"{user_agent_name}/{__version__}" - - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = os.path.expanduser(config["security_token_file"]) - token = None - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - return oci.core.ComputeClient(config, signer=signer) - - @mcp.tool(description="List Instances in a given compartment") +@with_oci_client(oci.core.ComputeClient) def list_instances( compartment_id: str = Field(..., description="The OCID of the compartment"), limit: Optional[int] = Field( @@ -74,12 +58,12 @@ def list_instances( "TERMINATED", ] ] = Field(None, description="The lifecycle state of the instance to filter on"), + *, + client: oci.core.ComputeClient, ) -> list[Instance]: instances: list[Instance] = [] try: - client = get_compute_client() - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -112,12 +96,13 @@ def list_instances( @mcp.tool(description="Get Instance with a given instance OCID") +@with_oci_client(oci.core.ComputeClient) def get_instance( - instance_id: str = Field(..., description="The OCID of the instance") + instance_id: str = Field(..., description="The OCID of the instance"), + *, + client: oci.core.ComputeClient, ) -> Instance: try: - client = get_compute_client() - response: oci.response.Response = client.get_instance(instance_id=instance_id) data: oci.core.models.Instance = response.data logger.info("Found Instance") @@ -132,6 +117,7 @@ def get_instance( description="Create a new instance. " "Another word for instance could be compute, server, or virtual machine" ) +@with_oci_client(oci.core.ComputeClient) def launch_instance( compartment_id: str = Field( ..., @@ -187,10 +173,10 @@ def launch_instance( DEFAULT_MEMORY_IN_GBS, description="The total amount of memory in gigabytes to assigned to the instance", ), + *, + client: oci.core.ComputeClient, ) -> Instance: try: - client = get_compute_client() - launch_details = oci.core.models.LaunchInstanceDetails( compartment_id=compartment_id, display_name=display_name, @@ -216,12 +202,13 @@ def launch_instance( @mcp.tool(description="Delete instance with given instance OCID") +@with_oci_client(oci.core.ComputeClient) def terminate_instance( - instance_id: str = Field(..., description="The OCID of the instance") + instance_id: str = Field(..., description="The OCID of the instance"), + *, + client: oci.core.ComputeClient, ) -> Response: try: - client = get_compute_client() - response: oci.response.Response = client.terminate_instance(instance_id) logger.info("Deleted Instance") return map_response(response) @@ -234,6 +221,7 @@ def terminate_instance( @mcp.tool( description="Update instance. This may restart the instance, so warn the user" ) +@with_oci_client(oci.core.ComputeClient) def update_instance( instance_id: str = Field(..., description="The OCID of the instance"), ocpus: Optional[int] = Field( @@ -244,10 +232,10 @@ def update_instance( None, description="The total amount of memory in gigabytes to assigned to the instance", ), + *, + client: oci.core.ComputeClient, ) -> Instance: try: - client = get_compute_client() - update_instance_details = oci.core.models.UpdateInstanceDetails( shape_config=oci.core.models.UpdateInstanceShapeConfigDetails( ocpus=ocpus, memory_in_gbs=memory_in_gbs @@ -270,6 +258,7 @@ def update_instance( description="List images in a given compartment, " "optionally filtered by operating system" ) +@with_oci_client(oci.core.ComputeClient) def list_images( compartment_id: str = Field(..., description="The OCID of the compartment"), operating_system: Optional[str] = Field( @@ -280,12 +269,12 @@ def list_images( description="The maximum amount of resources to return. If None, there is no limit.", ge=1, ), + *, + client: oci.core.ComputeClient, ) -> list[Image]: images: list[Image] = [] try: - client = get_compute_client() - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -318,10 +307,13 @@ def list_images( @mcp.tool(description="Get Image with a given image OCID") -def get_image(image_id: str = Field(..., description="The OCID of the image")) -> Image: +@with_oci_client(oci.core.ComputeClient) +def get_image( + image_id: str = Field(..., description="The OCID of the image"), + *, + client: oci.core.ComputeClient, +) -> Image: try: - client = get_compute_client() - response: oci.response.Response = client.get_image(image_id=image_id) data: oci.core.models.Image = response.data logger.info("Found Image") @@ -333,6 +325,7 @@ def get_image(image_id: str = Field(..., description="The OCID of the image")) - @mcp.tool(description="Perform the desired action on a given instance") +@with_oci_client(oci.core.ComputeClient) def instance_action( instance_id: str = Field(..., description="The OCID of the instance"), action: Literal[ @@ -345,10 +338,10 @@ def instance_action( "DIAGNOSTICREBOOT", "REBOOTMIGRATE", ] = Field(..., description="The instance action to be performed"), + *, + client: oci.core.ComputeClient, ) -> Instance: try: - client = get_compute_client() - response: oci.response.Response = client.instance_action(instance_id, action) data: oci.core.models.Instance = response.data logger.info("Performed instance action") @@ -362,6 +355,7 @@ def instance_action( @mcp.tool( description="List vnic attachments in a given compartment and/or on a given instance. " ) +@with_oci_client(oci.core.ComputeClient) def list_vnic_attachments( compartment_id: str = Field( ..., @@ -375,12 +369,12 @@ def list_vnic_attachments( description="The maximum amount of resources to return. If None, there is no limit.", ge=1, ), + *, + client: oci.core.ComputeClient, ) -> list[VnicAttachment]: vnic_attachments: list[VnicAttachment] = [] try: - client = get_compute_client() - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -413,12 +407,13 @@ def list_vnic_attachments( @mcp.tool(description="Get Vnic Attachment with a given OCID") +@with_oci_client(oci.core.ComputeClient) def get_vnic_attachment( - vnic_attachment_id: str = Field(..., description="The OCID of the vnic attachment") + vnic_attachment_id: str = Field(..., description="The OCID of the vnic attachment"), + *, + client: oci.core.ComputeClient, ) -> VnicAttachment: try: - client = get_compute_client() - response: oci.response.Response = client.get_vnic_attachment( vnic_attachment_id=vnic_attachment_id ) diff --git a/src/oci-compute-mcp-server/oracle/oci_compute_mcp_server/tests/test_compute_tools.py b/src/oci-compute-mcp-server/oracle/oci_compute_mcp_server/tests/test_compute_tools.py index 0c4bf019..0cd1d9b4 100644 --- a/src/oci-compute-mcp-server/oracle/oci_compute_mcp_server/tests/test_compute_tools.py +++ b/src/oci-compute-mcp-server/oracle/oci_compute_mcp_server/tests/test_compute_tools.py @@ -4,11 +4,10 @@ https://oss.oracle.com/licenses/upl. """ -from unittest.mock import MagicMock, create_autospec, mock_open, patch +from unittest.mock import MagicMock, create_autospec, patch import fastmcp.exceptions import oci -import oracle.oci_compute_mcp_server.server as server import pytest from fastmcp import Client from oracle.oci_compute_mcp_server.server import mcp @@ -16,10 +15,10 @@ class TestComputeTools: @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_list_instances(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_instances(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = [ @@ -45,10 +44,10 @@ async def test_list_instances(self, mock_get_client): assert result[0]["id"] == "instance1" @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_list_instances_exception(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_instances_exception(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client # Mock the client to raise an exception mock_client.list_instances.side_effect = oci.exceptions.ServiceError( @@ -72,10 +71,10 @@ async def test_list_instances_exception(self, mock_get_client): assert "'message': 'Internal server error'" in str(e.value) @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_get_instance(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_instance(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.core.models.Instance( @@ -92,10 +91,10 @@ async def test_get_instance(self, mock_get_client): assert result["id"] == "instance1" @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_get_instance_exception(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_instance_exception(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client # Mock the client to raise an exception mock_client.get_instance.side_effect = oci.exceptions.ServiceError( @@ -117,10 +116,10 @@ async def test_get_instance_exception(self, mock_get_client): assert "'message': 'Internal server error'" in str(e.value) @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_launch_instance(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_launch_instance(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_launch_response = create_autospec(oci.response.Response) mock_launch_response.data = oci.core.models.Instance( @@ -146,10 +145,10 @@ async def test_launch_instance(self, mock_get_client): assert result["lifecycle_state"] == "PROVISIONING" @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_launch_instance_exception(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_launch_instance_exception(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client # Mock the client to raise an exception mock_client.launch_instance.side_effect = oci.exceptions.ServiceError( @@ -180,10 +179,10 @@ async def test_launch_instance_exception(self, mock_get_client): assert "'message': 'Internal server error'" in str(e.value) @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_terminate_instance(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_terminate_instance(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_delete_response = create_autospec(oci.response.Response) mock_delete_response.status = 204 @@ -201,10 +200,10 @@ async def test_terminate_instance(self, mock_get_client): assert result["status"] == 204 @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_terminate_instance_exception(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_terminate_instance_exception(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client # Mock the client to raise an exception mock_client.terminate_instance.side_effect = oci.exceptions.ServiceError( @@ -231,13 +230,13 @@ async def test_terminate_instance_exception(self, mock_get_client): assert "'message': 'Internal server error'" in str(e.value) @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_update_instance(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_update_instance(self, mock_create_client): ocpus = 2 memory_in_gbs = 16 mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_update_response = create_autospec(oci.response.Response) mock_update_response.data = oci.core.models.Instance( @@ -263,10 +262,10 @@ async def test_update_instance(self, mock_get_client): assert result["shape_config"]["memory_in_gbs"] == memory_in_gbs @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_update_instance_exception(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_update_instance_exception(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client # Mock the client to raise an exception mock_client.update_instance.side_effect = oci.exceptions.ServiceError( @@ -295,10 +294,10 @@ async def test_update_instance_exception(self, mock_get_client): assert "'message': 'Internal server error'" in str(e.value) @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_list_images(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_images(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = [ @@ -327,10 +326,10 @@ async def test_list_images(self, mock_get_client): assert result[0]["id"] == "image1" @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_list_images_exception(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_images_exception(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client # Mock the client to raise an exception mock_client.list_images.side_effect = oci.exceptions.ServiceError( @@ -357,10 +356,10 @@ async def test_list_images_exception(self, mock_get_client): assert "'message': 'Internal server error'" in str(e.value) @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_get_image(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_image(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.core.models.Image( @@ -381,10 +380,10 @@ async def test_get_image(self, mock_get_client): assert result["id"] == "image1" @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_get_image_exception(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_image_exception(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client # Mock the client to raise an exception mock_client.get_image.side_effect = oci.exceptions.ServiceError( @@ -411,10 +410,10 @@ async def test_get_image_exception(self, mock_get_client): assert "'message': 'Internal server error'" in str(e.value) @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_instance_action(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_instance_action(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_action_response = create_autospec(oci.response.Response) mock_action_response.data = oci.core.models.Instance( @@ -439,10 +438,10 @@ async def test_instance_action(self, mock_get_client): assert result["lifecycle_state"] == "STOPPING" @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_instance_action_exception(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_instance_action_exception(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client # Mock the client to raise an exception mock_client.instance_action.side_effect = oci.exceptions.ServiceError( @@ -470,10 +469,10 @@ async def test_instance_action_exception(self, mock_get_client): assert "'message': 'Internal server error'" in str(e.value) @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_list_vnic_attachments(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_vnic_attachments(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = [ @@ -498,10 +497,10 @@ async def test_list_vnic_attachments(self, mock_get_client): assert result[0]["id"] == "vnicattachment1" @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_list_vnic_attachments_exception(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_vnic_attachments_exception(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client # Mock the client to raise an exception mock_client.list_vnic_attachments.side_effect = oci.exceptions.ServiceError( @@ -528,10 +527,10 @@ async def test_list_vnic_attachments_exception(self, mock_get_client): assert "'message': 'Internal server error'" in str(e.value) @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_get_vnic_attachment(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_vnic_attachment(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.core.models.VnicAttachment( @@ -550,10 +549,10 @@ async def test_get_vnic_attachment(self, mock_get_client): assert result["id"] == "vnicattachment1" @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_get_vnic_attachment_exception(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_vnic_attachment_exception(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client # Mock the client to raise an exception mock_client.get_vnic_attachment.side_effect = oci.exceptions.ServiceError( @@ -630,10 +629,10 @@ def test_main_with_only_port(self, mock_getenv, mock_mcp_run): mock_mcp_run.assert_called_once_with() @pytest.mark.asyncio - @patch("oracle.oci_compute_mcp_server.server.get_compute_client") - async def test_list_images_without_filter(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_images_without_filter(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = [ @@ -665,108 +664,3 @@ async def test_list_images_without_filter(self, mock_get_client): assert len(result) == 2 assert {img["id"] for img in result} == {"image1", "image2"} - - -class TestGetClient: - @patch("oracle.oci_compute_mcp_server.server.oci.core.ComputeClient") - @patch("oracle.oci_compute_mcp_server.server.oci.auth.signers.SecurityTokenSigner") - @patch("oracle.oci_compute_mcp_server.server.oci.signer.load_private_key_from_file") - @patch( - "oracle.oci_compute_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_compute_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_compute_mcp_server.server.os.getenv") - def test_get_compute_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default - ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - - # Act - result = server.get_compute_client() - - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - expected_user_agent = f"{server.__project__.split('oracle.', 1)[1].split('-server', 1)[0]}/{server.__version__}" # noqa - assert passed_config.get("additional_user_agent") == expected_user_agent - # And we returned the client instance - assert result == mock_client.return_value - - @patch("oracle.oci_compute_mcp_server.server.oci.core.ComputeClient") - @patch("oracle.oci_compute_mcp_server.server.oci.auth.signers.SecurityTokenSigner") - @patch("oracle.oci_compute_mcp_server.server.oci.signer.load_private_key_from_file") - @patch( - "oracle.oci_compute_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_compute_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_compute_mcp_server.server.os.getenv") - def test_get_compute_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_compute_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - assert "additional_user_agent" in config - assert ( - isinstance(config["additional_user_agent"], str) - and "/" in config["additional_user_agent"] - ) - # Returned object is client instance - assert srv_client is mock_client.return_value diff --git a/src/oci-compute-mcp-server/pyproject.toml b/src/oci-compute-mcp-server/pyproject.toml index d8bd607d..6d91e0c2 100644 --- a/src/oci-compute-mcp-server/pyproject.toml +++ b/src/oci-compute-mcp-server/pyproject.toml @@ -54,3 +54,10 @@ omit = [ ] precision = 2 fail_under = 90 + + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-database-mcp-server/pyproject.toml b/src/oci-database-mcp-server/pyproject.toml index 329730dd..e834e061 100644 --- a/src/oci-database-mcp-server/pyproject.toml +++ b/src/oci-database-mcp-server/pyproject.toml @@ -38,3 +38,10 @@ dev = [ "pytest>=8.4.2", "pytest-asyncio>=1.2.0", ] + + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-faaas-mcp-server/oracle/oci_faaas_mcp_server/server.py b/src/oci-faaas-mcp-server/oracle/oci_faaas_mcp_server/server.py index bf4c36a8..04ef3151 100644 --- a/src/oci-faaas-mcp-server/oracle/oci_faaas_mcp_server/server.py +++ b/src/oci-faaas-mcp-server/oracle/oci_faaas_mcp_server/server.py @@ -4,15 +4,15 @@ https://oss.oracle.com/licenses/upl. """ -import os from logging import Logger from typing import Any, Literal, Optional import oci from fastmcp import FastMCP +from oracle.mcp_common import with_oci_client from pydantic import Field -from . import __project__, __version__ +from . import __project__ from .models import ( FusionEnvironment, FusionEnvironmentFamily, @@ -27,31 +27,10 @@ mcp = FastMCP(name=__project__) -def get_faaas_client(): - """Initialize and return an OCI Fusion Applications client using security token auth.""" - logger.info("entering get_faaas_client") - - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - - user_agent_name = __project__.split("oracle.", 1)[1].split("-server", 1)[0] - config["additional_user_agent"] = f"{user_agent_name}/{__version__}" - - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = config["security_token_file"] - token = None - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - - return oci.fusion_apps.FusionApplicationsClient(config, signer=signer) - - @mcp.tool( description="Returns a list of Fusion Environment Families in the specified compartment." ) +@with_oci_client(oci.fusion_apps.FusionApplicationsClient) def list_fusion_environment_families( compartment_id: str = Field( ..., description="The ID of the compartment in which to list resources." @@ -68,8 +47,9 @@ def list_fusion_environment_families( "DELETING, DELETED, FAILED" ), ), + *, + client: oci.fusion_apps.FusionApplicationsClient, ) -> list[FusionEnvironmentFamily]: - client = get_faaas_client() families: list[FusionEnvironmentFamily] = [] next_page: Optional[str] = None @@ -119,6 +99,7 @@ def list_fusion_environment_families( "(optionally filtered by family)." ) ) +@with_oci_client(oci.fusion_apps.FusionApplicationsClient) def list_fusion_environments( compartment_id: str = Field( ..., description="The ID of the compartment in which to list resources." @@ -146,8 +127,9 @@ def list_fusion_environments( "INACTIVE, DELETING, DELETED, FAILED" ), ), + *, + client: oci.fusion_apps.FusionApplicationsClient, ) -> list[FusionEnvironment]: - client = get_faaas_client() environments: list[FusionEnvironment] = [] next_page: Optional[str] = None @@ -192,12 +174,14 @@ def list_fusion_environments( @mcp.tool(description="Gets a Fusion Environment by OCID.") +@with_oci_client(oci.fusion_apps.FusionApplicationsClient) def get_fusion_environment( fusion_environment_id: str = Field( ..., description="Unique FusionEnvironment identifier (OCID)" ), + *, + client: oci.fusion_apps.FusionApplicationsClient, ) -> FusionEnvironment: - client = get_faaas_client() response: oci.response.Response = client.get_fusion_environment( fusion_environment_id ) @@ -205,12 +189,14 @@ def get_fusion_environment( @mcp.tool(description="Gets the status of a Fusion Environment by OCID.") +@with_oci_client(oci.fusion_apps.FusionApplicationsClient) def get_fusion_environment_status( fusion_environment_id: str = Field( ..., description="Unique FusionEnvironment identifier (OCID)" ), + *, + client: oci.fusion_apps.FusionApplicationsClient, ) -> FusionEnvironmentStatus: - client = get_faaas_client() response: oci.response.Response = client.get_fusion_environment_status( fusion_environment_id ) diff --git a/src/oci-faaas-mcp-server/oracle/oci_faaas_mcp_server/tests/test_faaas_tools.py b/src/oci-faaas-mcp-server/oracle/oci_faaas_mcp_server/tests/test_faaas_tools.py index 24067f24..c7aeb9a8 100644 --- a/src/oci-faaas-mcp-server/oracle/oci_faaas_mcp_server/tests/test_faaas_tools.py +++ b/src/oci-faaas-mcp-server/oracle/oci_faaas_mcp_server/tests/test_faaas_tools.py @@ -6,9 +6,8 @@ import sys import types -from unittest.mock import MagicMock, create_autospec, mock_open, patch +from unittest.mock import MagicMock, create_autospec, patch -import oracle.oci_faaas_mcp_server.server as server import pytest from fastmcp import Client from oracle.oci_faaas_mcp_server.server import main, mcp @@ -36,7 +35,7 @@ class TestFaaasTools: @pytest.mark.asyncio - @patch("oracle.oci_faaas_mcp_server.server.get_faaas_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_fusion_environment_families(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -64,7 +63,7 @@ async def test_list_fusion_environment_families(self, mock_get_client): assert result[0]["id"] == "family1" @pytest.mark.asyncio - @patch("oracle.oci_faaas_mcp_server.server.get_faaas_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_fusion_environments(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -96,7 +95,7 @@ async def test_list_fusion_environments(self, mock_get_client): assert result[0]["id"] == "env1" @pytest.mark.asyncio - @patch("oracle.oci_faaas_mcp_server.server.get_faaas_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_get_fusion_environment(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -120,7 +119,7 @@ async def test_get_fusion_environment(self, mock_get_client): assert result["display_name"] == "Env 1" @pytest.mark.asyncio - @patch("oracle.oci_faaas_mcp_server.server.get_faaas_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_get_fusion_environment_status(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -144,7 +143,7 @@ async def test_get_fusion_environment_status(self, mock_get_client): assert result["status"] == "ACTIVE" @pytest.mark.asyncio - @patch("oracle.oci_faaas_mcp_server.server.get_faaas_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_fusion_environment_families_pagination_header_fallback( self, mock_get_client ): @@ -178,7 +177,7 @@ async def test_list_fusion_environment_families_pagination_header_fallback( assert mock_client.list_fusion_environment_families.call_count == 2 @pytest.mark.asyncio - @patch("oracle.oci_faaas_mcp_server.server.get_faaas_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_fusion_environments_pagination_and_filters( self, mock_get_client ): @@ -233,7 +232,7 @@ async def test_main_runs_mcp_run(self): mock_run.assert_called_once() @pytest.mark.asyncio - @patch("oracle.oci_faaas_mcp_server.server.get_faaas_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_fusion_environment_families_handles_items_attr_and_filters( self, mock_get_client ): @@ -269,7 +268,7 @@ async def test_list_fusion_environment_families_handles_items_attr_and_filters( assert kwargs["lifecycle_state"] == "ACTIVE" @pytest.mark.asyncio - @patch("oracle.oci_faaas_mcp_server.server.get_faaas_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_fusion_environment_families_headers_exception_fallback( self, mock_get_client ): @@ -294,7 +293,7 @@ async def test_list_fusion_environment_families_headers_exception_fallback( mock_client.list_fusion_environment_families.assert_called_once() @pytest.mark.asyncio - @patch("oracle.oci_faaas_mcp_server.server.get_faaas_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_fusion_environments_data_single_object(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -317,112 +316,3 @@ async def test_list_fusion_environments_data_single_object(self, mock_get_client # Only required arg was passed kwargs = mock_client.list_fusion_environments.call_args.kwargs assert list(kwargs.keys()) == ["compartment_id"] - - -class TestGetClient: - @patch( - "oracle.oci_faaas_mcp_server.server.oci.fusion_apps.FusionApplicationsClient" - ) - @patch("oracle.oci_faaas_mcp_server.server.oci.auth.signers.SecurityTokenSigner") - @patch("oracle.oci_faaas_mcp_server.server.oci.signer.load_private_key_from_file") - @patch( - "oracle.oci_faaas_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_faaas_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_faaas_mcp_server.server.os.getenv") - def test_get_faaas_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default - ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - - # Act - result = server.get_faaas_client() - - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - expected_user_agent = f"{server.__project__.split('oracle.', 1)[1].split('-server', 1)[0]}/{server.__version__}" # noqa - assert passed_config.get("additional_user_agent") == expected_user_agent - # And we returned the client instance - assert result == mock_client.return_value - - @patch( - "oracle.oci_faaas_mcp_server.server.oci.fusion_apps.FusionApplicationsClient" - ) - @patch("oracle.oci_faaas_mcp_server.server.oci.auth.signers.SecurityTokenSigner") - @patch("oracle.oci_faaas_mcp_server.server.oci.signer.load_private_key_from_file") - @patch( - "oracle.oci_faaas_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_faaas_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_faaas_mcp_server.server.os.getenv") - def test_get_faaas_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_faaas_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - assert "additional_user_agent" in config - assert ( - isinstance(config["additional_user_agent"], str) - and "/" in config["additional_user_agent"] - ) - # Returned object is client instance - assert srv_client is mock_client.return_value diff --git a/src/oci-faaas-mcp-server/pyproject.toml b/src/oci-faaas-mcp-server/pyproject.toml index f948a6e1..4a598e99 100644 --- a/src/oci-faaas-mcp-server/pyproject.toml +++ b/src/oci-faaas-mcp-server/pyproject.toml @@ -53,3 +53,10 @@ omit = [ ] precision = 2 fail_under = 90 + + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-logging-mcp-server/oracle/oci_logging_mcp_server/server.py b/src/oci-logging-mcp-server/oracle/oci_logging_mcp_server/server.py index b8222956..0744d7b2 100644 --- a/src/oci-logging-mcp-server/oracle/oci_logging_mcp_server/server.py +++ b/src/oci-logging-mcp-server/oracle/oci_logging_mcp_server/server.py @@ -11,6 +11,7 @@ import oci from fastmcp import FastMCP +from oracle.mcp_common import with_oci_client from oracle.oci_logging_mcp_server.models import ( Log, LogGroup, @@ -37,42 +38,11 @@ mcp = FastMCP(name=__project__) -def get_logging_client(): - logger.info("entering get_logging_client") - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = os.path.expanduser(config["security_token_file"]) - token = None - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - return oci.logging.LoggingManagementClient(config, signer=signer) - - -def get_logging_search_client(): - logger.info("entering get_logging_client") - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = os.path.expanduser(config["security_token_file"]) - token = None - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - return oci.loggingsearch.LogSearchClient(config, signer=signer) - - @mcp.tool( description="List Log Groups in a given compartment." "Only use this tool if the user specifically mentions Log Groups" ) +@with_oci_client(oci.logging.LoggingManagementClient) def list_log_groups( compartment_id: str = Field(..., description="The OCID of the compartment"), limit: Optional[int] = Field( @@ -80,12 +50,12 @@ def list_log_groups( description="The maximum amount of resources to return. If None, there is no limit.", ge=1, ), + *, + client: oci.logging.LoggingManagementClient, ) -> list[LogGroupSummary]: log_groups: list[LogGroupSummary] = [] try: - client = get_logging_client() - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -117,14 +87,15 @@ def list_log_groups( description="Fetch the details of a log group." "Only use this tool if the user specifically mentions Log Groups" ) +@with_oci_client(oci.logging.LoggingManagementClient) def get_log_group( log_group_id: str = Field( ..., description="The OCID of the log group that the log belongs to." ), + *, + client: oci.logging.LoggingManagementClient, ) -> LogGroup: try: - client = get_logging_client() - response: oci.response.Response = client.get_log_group( log_group_id=log_group_id ) @@ -141,6 +112,7 @@ def get_log_group( description="List Log Groups in a given log group." "Only use this tool if the user explicitly supplies a Log Group OCID" ) +@with_oci_client(oci.logging.LoggingManagementClient) def list_logs( log_group_id: str = Field( ..., description="The OCID of the log group to list logs from." @@ -150,12 +122,12 @@ def list_logs( description="The maximum amount of resources to return. If None, there is no limit.", ge=1, ), + *, + client: oci.logging.LoggingManagementClient, ) -> list[LogSummary]: logs: list[LogSummary] = [] try: - client = get_logging_client() - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -187,15 +159,16 @@ def list_logs( description="Fetch the details of a log. " "Only use this tool if the user explicitly supplies a Log Group OCID and a Log OCID" ) +@with_oci_client(oci.logging.LoggingManagementClient) def get_log( log_id: str = Field(..., description="The OCID of the log"), log_group_id: str = Field( ..., description="The OCID of the log group that the log belongs to." ), + *, + client: oci.logging.LoggingManagementClient, ) -> Log: try: - client = get_logging_client() - response: oci.response.Response = client.get_log( log_group_id=log_group_id, log_id=log_id ) @@ -282,6 +255,7 @@ def get_paginated_event_types( "For a table full of all support event types, you MAY access get_paginated_event_types." "Always include a link to the logging plugin with the inputted query and time range in the response." ) +@with_oci_client(oci.loggingsearch.LogSearchClient) def search_logs( time_start: str = Field( ..., @@ -305,10 +279,10 @@ def search_logs( page: Optional[str] = Field( None, description="The next page token for the search_logs API call. " ), + *, + client: oci.loggingsearch.LogSearchClient, ) -> SearchResponse: try: - client = get_logging_search_client() - search_logs_details = oci.loggingsearch.models.SearchLogsDetails( time_start=time_start, time_end=time_end, diff --git a/src/oci-logging-mcp-server/oracle/oci_logging_mcp_server/tests/test_logging_tools.py b/src/oci-logging-mcp-server/oracle/oci_logging_mcp_server/tests/test_logging_tools.py index 5ae063e9..4c8b398d 100644 --- a/src/oci-logging-mcp-server/oracle/oci_logging_mcp_server/tests/test_logging_tools.py +++ b/src/oci-logging-mcp-server/oracle/oci_logging_mcp_server/tests/test_logging_tools.py @@ -4,7 +4,7 @@ https://oss.oracle.com/licenses/upl. """ -from unittest.mock import MagicMock, create_autospec, mock_open, patch +from unittest.mock import MagicMock, create_autospec, patch import oci import oracle.oci_logging_mcp_server.server as server @@ -16,10 +16,10 @@ class TestLoggingTools: @pytest.mark.asyncio - @patch("oracle.oci_logging_mcp_server.server.get_logging_client") - async def test_list_log_groups(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_log_groups(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_summarize_response = create_autospec(oci.response.Response) mock_summarize_response.data = [ @@ -44,10 +44,10 @@ async def test_list_log_groups(self, mock_get_client): assert result[0]["id"] == "logGroup1" @pytest.mark.asyncio - @patch("oracle.oci_logging_mcp_server.server.get_logging_client") - async def test_list_log_groups_pagination_without_limit(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_log_groups_pagination_without_limit(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client # Page 1 resp1 = create_autospec(oci.response.Response) @@ -85,10 +85,10 @@ async def test_list_log_groups_pagination_without_limit(self, mock_get_client): assert second_kwargs["limit"] is None @pytest.mark.asyncio - @patch("oracle.oci_logging_mcp_server.server.get_logging_client") - async def test_list_log_groups_limit_stops_pagination(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_log_groups_limit_stops_pagination(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client resp1 = create_autospec(oci.response.Response) resp1.data = [ @@ -121,10 +121,10 @@ async def test_list_log_groups_limit_stops_pagination(self, mock_get_client): assert kwargs["page"] is None @pytest.mark.asyncio - @patch("oracle.oci_logging_mcp_server.server.get_logging_client") - async def test_get_log_group(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_log_group(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.logging.models.LogGroup( @@ -147,10 +147,10 @@ async def test_get_log_group(self, mock_get_client): assert result["display_name"] == "groupUp" @pytest.mark.asyncio - @patch("oracle.oci_logging_mcp_server.server.get_logging_client") - async def test_list_logs(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_logs(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_summarize_response = create_autospec(oci.response.Response) mock_summarize_response.data = [ @@ -175,10 +175,10 @@ async def test_list_logs(self, mock_get_client): assert result[0]["display_name"] == "logjam" @pytest.mark.asyncio - @patch("oracle.oci_logging_mcp_server.server.get_logging_client") - async def test_list_logs_pagination_without_limit(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_logs_pagination_without_limit(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client # Page 1 resp1 = create_autospec(oci.response.Response) @@ -213,10 +213,10 @@ async def test_list_logs_pagination_without_limit(self, mock_get_client): assert second_kwargs["page"] == "np" @pytest.mark.asyncio - @patch("oracle.oci_logging_mcp_server.server.get_logging_client") - async def test_list_logs_limit_stops_pagination(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_logs_limit_stops_pagination(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client resp1 = create_autospec(oci.response.Response) resp1.data = [ @@ -249,10 +249,10 @@ async def test_list_logs_limit_stops_pagination(self, mock_get_client): assert kwargs["page"] is None @pytest.mark.asyncio - @patch("oracle.oci_logging_mcp_server.server.get_logging_client") - async def test_get_log(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_log(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.logging.models.Log( @@ -281,10 +281,10 @@ async def test_get_log(self, mock_get_client): assert result["retention_duration"] == 30 @pytest.mark.asyncio - @patch("oracle.oci_logging_mcp_server.server.get_logging_client") - async def test_list_log_groups_exception_propagates(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_log_groups_exception_propagates(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_client.list_log_groups.side_effect = RuntimeError("boom") async with Client(mcp) as client: @@ -295,10 +295,10 @@ async def test_list_log_groups_exception_propagates(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_logging_mcp_server.server.get_logging_client") - async def test_list_logs_exception_propagates(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_logs_exception_propagates(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_client.list_logs.side_effect = ValueError("err") async with Client(mcp) as client: @@ -306,10 +306,10 @@ async def test_list_logs_exception_propagates(self, mock_get_client): await client.call_tool("list_logs", {"log_group_id": "ocid1.loggroup"}) @pytest.mark.asyncio - @patch("oracle.oci_logging_mcp_server.server.get_logging_client") - async def test_get_log_exception_propagates(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_log_exception_propagates(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_client.get_log.side_effect = RuntimeError("boom") async with Client(mcp) as client: @@ -320,10 +320,10 @@ async def test_get_log_exception_propagates(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_logging_mcp_server.server.get_logging_search_client") - async def test_search_logs(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_search_logs(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.loggingsearch.models.SearchResponse( @@ -350,9 +350,9 @@ async def test_search_logs(self, mock_get_client): @pytest.mark.asyncio @patch("oracle.oci_logging_mcp_server.server.map_search_response") - @patch("oracle.oci_logging_mcp_server.server.get_logging_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_search_logs_oversize_raises_toolerror( - self, mock_get_client, mock_map + self, mock_create_client, mock_map ): class DummySearchResponse: def model_dump_json(self): @@ -360,7 +360,7 @@ def model_dump_json(self): return "x" * 60000 mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client resp = create_autospec(oci.response.Response) resp.data = object() # not used by our patched map @@ -503,197 +503,3 @@ def test_main_with_only_port(self, mock_getenv, mock_mcp_run): server.main() mock_mcp_run.assert_called_once_with() - - -class TestGetClient: - @patch("oracle.oci_logging_mcp_server.server.oci.logging.LoggingManagementClient") - @patch("oracle.oci_logging_mcp_server.server.oci.auth.signers.SecurityTokenSigner") - @patch("oracle.oci_logging_mcp_server.server.oci.signer.load_private_key_from_file") - @patch( - "oracle.oci_logging_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_logging_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_logging_mcp_server.server.os.getenv") - def test_get_logging_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default - ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - - # Act - result = server.get_logging_client() - - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - # And we returned the client instance - assert result == mock_client.return_value - - @patch("oracle.oci_logging_mcp_server.server.oci.logging.LoggingManagementClient") - @patch("oracle.oci_logging_mcp_server.server.oci.auth.signers.SecurityTokenSigner") - @patch("oracle.oci_logging_mcp_server.server.oci.signer.load_private_key_from_file") - @patch( - "oracle.oci_logging_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_logging_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_logging_mcp_server.server.os.getenv") - def test_get_logging_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_logging_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - # Returned object is client instance - assert srv_client is mock_client.return_value - - @patch("oracle.oci_logging_mcp_server.server.oci.loggingsearch.LogSearchClient") - @patch("oracle.oci_logging_mcp_server.server.oci.auth.signers.SecurityTokenSigner") - @patch("oracle.oci_logging_mcp_server.server.oci.signer.load_private_key_from_file") - @patch( - "oracle.oci_logging_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_logging_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_logging_mcp_server.server.os.getenv") - def test_get_logging_search_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default - ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - - # Act - result = server.get_logging_search_client() - - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - # And we returned the client instance - assert result == mock_client.return_value - - @patch("oracle.oci_logging_mcp_server.server.oci.loggingsearch.LogSearchClient") - @patch("oracle.oci_logging_mcp_server.server.oci.auth.signers.SecurityTokenSigner") - @patch("oracle.oci_logging_mcp_server.server.oci.signer.load_private_key_from_file") - @patch( - "oracle.oci_logging_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_logging_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_logging_mcp_server.server.os.getenv") - def test_get_logging_search_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_logging_search_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - # Returned object is client instance - assert srv_client is mock_client.return_value diff --git a/src/oci-logging-mcp-server/pyproject.toml b/src/oci-logging-mcp-server/pyproject.toml index f3d94e74..3b573b02 100644 --- a/src/oci-logging-mcp-server/pyproject.toml +++ b/src/oci-logging-mcp-server/pyproject.toml @@ -52,3 +52,10 @@ omit = [ ] precision = 2 fail_under = 90 + + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-migration-mcp-server/oracle/oci_migration_mcp_server/server.py b/src/oci-migration-mcp-server/oracle/oci_migration_mcp_server/server.py index 2bcf48f5..0f6c5300 100644 --- a/src/oci-migration-mcp-server/oracle/oci_migration_mcp_server/server.py +++ b/src/oci-migration-mcp-server/oracle/oci_migration_mcp_server/server.py @@ -10,6 +10,7 @@ import oci from fastmcp import FastMCP +from oracle.mcp_common import with_oci_client from oracle.oci_migration_mcp_server.models import ( Migration, MigrationSummary, @@ -18,37 +19,21 @@ ) from pydantic import Field -from . import __project__, __version__ +from . import __project__ logger = Logger(__name__, level="INFO") mcp = FastMCP(name=__project__) -def get_migration_client(): - logger.info("entering get_migration_client") - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - user_agent_name = __project__.split("oracle.", 1)[1].split("-server", 1)[0] - config["additional_user_agent"] = f"{user_agent_name}/{__version__}" - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = os.path.expanduser(config["security_token_file"]) - token = None - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - return oci.cloud_migrations.MigrationClient(config, signer=signer) - - @mcp.tool(description="Get details for a specific Migration Project by OCID") +@with_oci_client(oci.cloud_migrations.MigrationClient) def get_migration( - migration_id: str = Field(..., description="OCID of the migration project") + migration_id: str = Field(..., description="OCID of the migration project"), + *, + client: oci.cloud_migrations.MigrationClient, ) -> Migration: try: - client = get_migration_client() - response: oci.response.Response = client.get_migration(migration_id) data: oci.cloud_migrations.models.Migration = response.data logger.info("Found Migration") @@ -62,6 +47,7 @@ def get_migration( @mcp.tool( description="List Migration Projects for a compartment, optionally filtered by lifecycle state" ) +@with_oci_client(oci.cloud_migrations.MigrationClient) def list_migrations( compartment_id: str = Field(..., description="The OCID of the compartment"), limit: Optional[int] = Field( @@ -80,23 +66,27 @@ def list_migrations( "FAILED", ] ] = Field(None, description="The lifecycle state of the migration to filter on"), + *, + client: oci.cloud_migrations.MigrationClient, ) -> list[MigrationSummary]: - migrations: list[Migration] = [] + migrations: list[MigrationSummary] = [] try: - client = get_migration_client() - - response: oci.response.Response = None has_next_page = True - next_page: str = None + next_page: Optional[str] = None while has_next_page and (limit is None or len(migrations) < limit): kwargs = { "compartment_id": compartment_id, "page": next_page, - "limit": limit, } + if limit is not None: + remaining = max(limit - len(migrations), 0) + if remaining == 0: + break + kwargs["limit"] = remaining + if lifecycle_state is not None: kwargs["lifecycle_state"] = lifecycle_state diff --git a/src/oci-migration-mcp-server/oracle/oci_migration_mcp_server/tests/test_migration_tools.py b/src/oci-migration-mcp-server/oracle/oci_migration_mcp_server/tests/test_migration_tools.py index 852dfe2a..51f99775 100644 --- a/src/oci-migration-mcp-server/oracle/oci_migration_mcp_server/tests/test_migration_tools.py +++ b/src/oci-migration-mcp-server/oracle/oci_migration_mcp_server/tests/test_migration_tools.py @@ -5,10 +5,9 @@ """ from types import SimpleNamespace -from unittest.mock import MagicMock, create_autospec, mock_open, patch +from unittest.mock import MagicMock, create_autospec, patch import oci -import oracle.oci_migration_mcp_server.server as server import pytest from fastmcp import Client from fastmcp.exceptions import ToolError @@ -17,10 +16,10 @@ class TestMigrationTools: @pytest.mark.asyncio - @patch("oracle.oci_migration_mcp_server.server.get_migration_client") - async def test_get_migration(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_migration(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.cloud_migrations.models.Migration( @@ -37,10 +36,10 @@ async def test_get_migration(self, mock_get_client): assert result["id"] == "migration1" @pytest.mark.asyncio - @patch("oracle.oci_migration_mcp_server.server.get_migration_client") - async def test_list_migrations(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_migrations(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = oci.cloud_migrations.models.MigrationCollection( @@ -71,60 +70,11 @@ async def test_list_migrations(self, mock_get_client): assert result[0]["id"] == "migration1" -class TestServer: - @patch("oracle.oci_migration_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_host_and_port(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_HOST": "1.2.3.4", - "ORACLE_MCP_PORT": "8888", - } - - mock_getenv.side_effect = lambda x: mock_env.get(x) - - server.main() - mock_mcp_run.assert_called_once_with( - transport="http", - host=mock_env["ORACLE_MCP_HOST"], - port=int(mock_env["ORACLE_MCP_PORT"]), - ) - - @patch("oracle.oci_migration_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_without_host_and_port(self, mock_getenv, mock_mcp_run): - mock_getenv.return_value = None - - server.main() - mock_mcp_run.assert_called_once_with() - - @patch("oracle.oci_migration_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_only_host(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_HOST": "1.2.3.4", - } - mock_getenv.side_effect = lambda x: mock_env.get(x) - - server.main() - mock_mcp_run.assert_called_once_with() - - @patch("oracle.oci_migration_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_only_port(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_PORT": "8888", - } - mock_getenv.side_effect = lambda x: mock_env.get(x) - - server.main() - mock_mcp_run.assert_called_once_with() - - @pytest.mark.asyncio -@patch("oracle.oci_migration_mcp_server.server.get_migration_client") -async def test_list_migrations_pagination_without_limit(mock_get_client): +@patch("oracle.mcp_common.helpers._create_oci_client") +async def test_list_migrations_pagination_without_limit(mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client resp1 = create_autospec(oci.response.Response) resp1.data = SimpleNamespace( @@ -159,16 +109,16 @@ async def test_list_migrations_pagination_without_limit(mock_get_client): first_kwargs = mock_client.list_migrations.call_args_list[0].kwargs second_kwargs = mock_client.list_migrations.call_args_list[1].kwargs assert first_kwargs["page"] is None - assert first_kwargs["limit"] is None + assert "limit" not in first_kwargs assert second_kwargs["page"] == "np1" - assert second_kwargs["limit"] is None + assert "limit" not in second_kwargs @pytest.mark.asyncio -@patch("oracle.oci_migration_mcp_server.server.get_migration_client") -async def test_list_migrations_limit_stops_pagination(mock_get_client): +@patch("oracle.mcp_common.helpers._create_oci_client") +async def test_list_migrations_limit_stops_pagination(mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client resp1 = create_autospec(oci.response.Response) resp1.data = SimpleNamespace( @@ -204,10 +154,10 @@ async def test_list_migrations_limit_stops_pagination(mock_get_client): @pytest.mark.asyncio -@patch("oracle.oci_migration_mcp_server.server.get_migration_client") -async def test_list_migrations_includes_lifecycle_state_filter(mock_get_client): +@patch("oracle.mcp_common.helpers._create_oci_client") +async def test_list_migrations_includes_lifecycle_state_filter(mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client resp = create_autospec(oci.response.Response) resp.data = SimpleNamespace(items=[oci.cloud_migrations.models.Migration(id="x")]) @@ -226,10 +176,10 @@ async def test_list_migrations_includes_lifecycle_state_filter(mock_get_client): @pytest.mark.asyncio -@patch("oracle.oci_migration_mcp_server.server.get_migration_client") -async def test_get_migration_exception_propagates(mock_get_client): +@patch("oracle.mcp_common.helpers._create_oci_client") +async def test_get_migration_exception_propagates(mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_client.get_migration.side_effect = RuntimeError("boom") async with Client(mcp) as client: @@ -238,10 +188,10 @@ async def test_get_migration_exception_propagates(mock_get_client): @pytest.mark.asyncio -@patch("oracle.oci_migration_mcp_server.server.get_migration_client") -async def test_list_migrations_exception_propagates(mock_get_client): +@patch("oracle.mcp_common.helpers._create_oci_client") +async def test_list_migrations_exception_propagates(mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_client.list_migrations.side_effect = ValueError("err") async with Client(mcp) as client: @@ -249,120 +199,3 @@ async def test_list_migrations_exception_propagates(mock_get_client): await client.call_tool( "list_migrations", {"compartment_id": "ocid1.tenancy"} ) - - -class TestGetClient: - @patch( - "oracle.oci_migration_mcp_server.server.oci.cloud_migrations.MigrationClient" - ) - @patch( - "oracle.oci_migration_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_migration_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_migration_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_migration_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_migration_mcp_server.server.os.getenv") - def test_get_migration_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default - ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - - # Act - result = server.get_migration_client() - - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - expected_user_agent = f"{server.__project__.split('oracle.', 1)[1].split('-server', 1)[0]}/{server.__version__}" # noqa - assert passed_config.get("additional_user_agent") == expected_user_agent - # And we returned the client instance - assert result == mock_client.return_value - - @patch( - "oracle.oci_migration_mcp_server.server.oci.cloud_migrations.MigrationClient" - ) - @patch( - "oracle.oci_migration_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_migration_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_migration_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_migration_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_migration_mcp_server.server.os.getenv") - def test_get_migration_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_migration_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - assert "additional_user_agent" in config - assert ( - isinstance(config["additional_user_agent"], str) - and "/" in config["additional_user_agent"] - ) - # Returned object is client instance - assert srv_client is mock_client.return_value diff --git a/src/oci-migration-mcp-server/pyproject.toml b/src/oci-migration-mcp-server/pyproject.toml index b5593b25..de971fac 100644 --- a/src/oci-migration-mcp-server/pyproject.toml +++ b/src/oci-migration-mcp-server/pyproject.toml @@ -49,3 +49,10 @@ omit = [ [tool.coverage.report] precision = 2 fail_under = 53.84 + + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/server.py b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/server.py index d22403c0..2434dc7b 100644 --- a/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/server.py +++ b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/server.py @@ -13,6 +13,7 @@ from fastmcp import Context, FastMCP from oci import Response from oci.monitoring.models import ListMetricsDetails, SummarizeMetricsDataDetails +from oracle.mcp_common import with_oci_client from oracle.oci_monitoring_mcp_server.alarm_models import ( AlarmSummary, map_alarm_summary, @@ -30,7 +31,7 @@ from oracle.oci_monitoring_mcp_server.scripts import MQL_QUERY_DOC, get_script_content from pydantic import Field -from . import __project__, __version__ +from . import __project__ logger = Logger(__name__, level="INFO") @@ -41,36 +42,18 @@ ) -def get_monitoring_client(): - logger.info("entering get_monitoring_client") - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - user_agent_name = __project__.split("oracle.", 1)[1].split("-server", 1)[0] - config["additional_user_agent"] = f"{user_agent_name}/{__version__}" - - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = os.path.expanduser(config["security_token_file"]) - token = None - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - return oci.monitoring.MonitoringClient(config, signer=signer) - - @mcp.tool(name="list_alarms", description="Lists all alarms in a given compartment") +@with_oci_client(oci.monitoring.MonitoringClient) def list_alarms( compartment_id: Annotated[ str, "The ID of the compartment containing the resources" "monitored by the metric that you are searching for.", ], + *, + client: oci.monitoring.MonitoringClient, ) -> list[AlarmSummary] | str: - monitoring_client = get_monitoring_client() - response: Response | None = monitoring_client.list_alarms( - compartment_id=compartment_id - ) + response: Response | None = client.list_alarms(compartment_id=compartment_id) if response is None: logger.error("Received None response from list_metrics") return "There was no response returned from the Monitoring API" @@ -86,6 +69,7 @@ def list_alarms( "or want to see all the available metric namespaces in a compartment. " "If there are no results found, remove the metric name or namespace fields. ", ) +@with_oci_client(oci.monitoring.MonitoringClient) async def list_metric_definitions( context: Context, compartment_id: str = CompartmentField, @@ -120,18 +104,17 @@ async def list_metric_definitions( examples=["frontend-fleet"], ), compartment_id_in_subtree: bool = CompartmentIdInSubtreeField, + *, + client: oci.monitoring.MonitoringClient, ) -> List[Metric] | str: try: - # Create client - monitoring_client = get_monitoring_client() - list_metrics_details = ListMetricsDetails( name=metric_name, namespace=namespace, resource_group=resource_group, group_by=group_by, ) - response: Response | None = monitoring_client.list_metrics( + response: Response | None = client.list_metrics( compartment_id, list_metrics_details=list_metrics_details, compartment_id_in_subtree=compartment_id_in_subtree, @@ -172,6 +155,7 @@ def _prepare_time_parameters(start_time, end_time) -> Tuple[datetime, datetime]: "suggest using another query or expanding the time range." "You MUST use the MQL Syntax Guide resource before using this tool to get the query.", ) +@with_oci_client(oci.monitoring.MonitoringClient) async def get_metrics_data( context: Context, compartment_id: str = CompartmentField, @@ -228,6 +212,8 @@ async def get_metrics_data( examples=["1m", "5m", "1h", "1d"], ), compartment_id_in_subtree: Optional[bool] = CompartmentIdInSubtreeField, + *, + client: oci.monitoring.MonitoringClient, ) -> List[MetricData] | str: try: # Process time parameters and calculate period @@ -237,9 +223,6 @@ async def get_metrics_data( logger.info(f"Calling get metrics data with these parameters: {query}") - # Create client - monitoring_client = get_monitoring_client() - # Call Summarize metrics data api and process the results summarize_metrics_data_details = SummarizeMetricsDataDetails( namespace=namespace, @@ -249,7 +232,7 @@ async def get_metrics_data( resource_group=resource_group, resolution=resolution, ) - response: Response | None = monitoring_client.summarize_metrics_data( + response: Response | None = client.summarize_metrics_data( compartment_id, summarize_metrics_data_details=summarize_metrics_data_details, compartment_id_in_subtree=compartment_id_in_subtree, diff --git a/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/tests/test_monitoring_tools.py b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/tests/test_monitoring_tools.py index 904384f6..42a7950d 100644 --- a/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/tests/test_monitoring_tools.py +++ b/src/oci-monitoring-mcp-server/oracle/oci_monitoring_mcp_server/tests/test_monitoring_tools.py @@ -5,7 +5,7 @@ """ from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import oci import pytest @@ -27,8 +27,8 @@ def mock_context(): class TestMonitoringTools: @pytest.mark.asyncio - @patch("oracle.oci_monitoring_mcp_server.server.get_monitoring_client") - async def test_get_metrics_data(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_metrics_data(self, mock_create_client): metric = oci.monitoring.models.MetricData( namespace="123", resource_group=None, @@ -40,12 +40,11 @@ async def test_get_metrics_data(self, mock_get_client): ], ) - mock_get_client.return_value = Mock() + monitoring_client = Mock() + mock_create_client.return_value = monitoring_client mock_list_response = Mock() mock_list_response.data = [metric] - mock_get_client.return_value.summarize_metrics_data.return_value = ( - mock_list_response - ) + monitoring_client.summarize_metrics_data.return_value = mock_list_response async with Client(mcp) as client: call_tool_result = await client.call_tool( @@ -66,8 +65,8 @@ async def test_get_metrics_data(self, mock_get_client): assert isinstance(metric["aggregated_datapoints"], list) @pytest.mark.asyncio - @patch("oracle.oci_monitoring_mcp_server.server.get_monitoring_client") - async def test_list_metric_definitions(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_metric_definitions(self, mock_create_client): metric1 = oci.monitoring.models.Metric( namespace="123", resource_group=None, @@ -82,10 +81,11 @@ async def test_list_metric_definitions(self, mock_get_client): compartment_id="compartment1", ) - mock_get_client.return_value = Mock() + monitoring_client = Mock() + mock_create_client.return_value = monitoring_client mock_list_response = Mock() mock_list_response.data = [metric1, metric2] - mock_get_client.return_value.list_metrics.return_value = mock_list_response + monitoring_client.list_metrics.return_value = mock_list_response async with Client(mcp) as client: call_tool_result = await client.call_tool( @@ -103,11 +103,12 @@ async def test_list_metric_definitions(self, mock_get_client): assert "namespace" in metric @pytest.mark.asyncio - @patch("oracle.oci_monitoring_mcp_server.server.get_monitoring_client") - async def test_list_metric_definitions_empty(self, mock_get_client): - mock_get_client.return_value = Mock() + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_metric_definitions_empty(self, mock_create_client): + monitoring_client = Mock() + mock_create_client.return_value = monitoring_client mock_list_response = None - mock_get_client.return_value.list_metrics.return_value = mock_list_response + monitoring_client.list_metrics.return_value = mock_list_response async with Client(mcp) as client: call_tool_result = await client.call_tool( @@ -121,8 +122,8 @@ async def test_list_metric_definitions_empty(self, mock_get_client): assert result == "There was no response returned from the Monitoring API" @pytest.mark.asyncio - @patch("oracle.oci_monitoring_mcp_server.server.get_monitoring_client") - async def test_list_alarms(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_alarms(self, mock_create_client): mock_alarm1 = oci.monitoring.models.Alarm( id="alarm1", display_name="Test Alarm 1", @@ -140,10 +141,11 @@ async def test_list_alarms(self, mock_get_client): query="MemoryUtilization[1m].mean() > 90", ) - mock_get_client.return_value = Mock() + monitoring_client = Mock() + mock_create_client.return_value = monitoring_client mock_list_response = Mock() mock_list_response.data = [mock_alarm1, mock_alarm2] - mock_get_client.return_value.list_alarms.return_value = mock_list_response + monitoring_client.list_alarms.return_value = mock_list_response async with Client(mcp) as client: call_tool_result = await client.call_tool( @@ -157,8 +159,8 @@ async def test_list_alarms(self, mock_get_client): assert "display_name" in alarm @pytest.mark.asyncio - @patch("oracle.oci_monitoring_mcp_server.server.get_monitoring_client") - async def test_list_alarms_with_overrides(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_alarms_with_overrides(self, mock_create_client): alarm_override = oci.monitoring.models.AlarmOverride( body="95% CPU utilization", query="CPUUtilization[1m].mean()>95", @@ -184,10 +186,11 @@ async def test_list_alarms_with_overrides(self, mock_get_client): query="MemoryUtilization[1m].mean() > 90", ) - mock_get_client.return_value = Mock() + monitoring_client = Mock() + mock_create_client.return_value = monitoring_client mock_list_response = Mock() mock_list_response.data = [mock_alarm1, mock_alarm2] - mock_get_client.return_value.list_alarms.return_value = mock_list_response + monitoring_client.list_alarms.return_value = mock_list_response async with Client(mcp) as client: call_tool_result = await client.call_tool( @@ -201,11 +204,12 @@ async def test_list_alarms_with_overrides(self, mock_get_client): assert "display_name" in alarm @pytest.mark.asyncio - @patch("oracle.oci_monitoring_mcp_server.server.get_monitoring_client") - async def test_list_alarms_no_response(self, mock_get_client): - mock_get_client.return_value = Mock() + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_alarms_no_response(self, mock_create_client): + monitoring_client = Mock() + mock_create_client.return_value = monitoring_client mock_list_response = None - mock_get_client.return_value.list_alarms.return_value = mock_list_response + monitoring_client.list_alarms.return_value = mock_list_response async with Client(mcp) as client: call_tool_result = await client.call_tool( @@ -215,10 +219,11 @@ async def test_list_alarms_no_response(self, mock_get_client): assert result == "There was no response returned from the Monitoring API" @pytest.mark.asyncio - @patch("oracle.oci_monitoring_mcp_server.server.get_monitoring_client") - async def test_get_metrics_data_empty_response(self, mock_get_client): - mock_get_client.return_value = Mock() - mock_get_client.return_value.summarize_metrics_data.return_value = None + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_metrics_data_empty_response(self, mock_create_client): + monitoring_client = Mock() + mock_create_client.return_value = monitoring_client + monitoring_client.summarize_metrics_data.return_value = None async with Client(mcp) as client: call_tool_result = await client.call_tool( @@ -244,169 +249,7 @@ def test_prepare_time_parameters(self): assert end.tzinfo is not None -class TestServer: - @patch("oracle.oci_monitoring_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_host_and_port(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_HOST": "1.2.3.4", - "ORACLE_MCP_PORT": "8888", - } - - mock_getenv.side_effect = lambda x: mock_env.get(x) - - server.main() - mock_mcp_run.assert_called_once_with( - transport="http", - host=mock_env["ORACLE_MCP_HOST"], - port=int(mock_env["ORACLE_MCP_PORT"]), - ) - - @patch("oracle.oci_monitoring_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_without_host_and_port(self, mock_getenv, mock_mcp_run): - mock_getenv.return_value = None - - server.main() - mock_mcp_run.assert_called_once_with() - - @patch("oracle.oci_monitoring_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_only_host(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_HOST": "1.2.3.4", - } - mock_getenv.side_effect = lambda x: mock_env.get(x) - - server.main() - mock_mcp_run.assert_called_once_with() - - @patch("oracle.oci_monitoring_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_only_port(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_PORT": "8888", - } - mock_getenv.side_effect = lambda x: mock_env.get(x) - - server.main() - mock_mcp_run.assert_called_once_with() - - class TestReadFile: def test_read_file(self): document = get_script_content(MQL_QUERY_DOC) assert document is not None - - -class TestGetClient: - @patch("oracle.oci_monitoring_mcp_server.server.oci.monitoring.MonitoringClient") - @patch( - "oracle.oci_monitoring_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_monitoring_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_monitoring_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_monitoring_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_monitoring_mcp_server.server.os.getenv") - def test_get_monitoring_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default - ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - - # Act - result = server.get_monitoring_client() - - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - expected_user_agent = f"{server.__project__.split('oracle.', 1)[1].split('-server', 1)[0]}/{server.__version__}" # noqa - assert passed_config.get("additional_user_agent") == expected_user_agent - # And we returned the client instance - assert result == mock_client.return_value - - @patch("oracle.oci_monitoring_mcp_server.server.oci.monitoring.MonitoringClient") - @patch( - "oracle.oci_monitoring_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_monitoring_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_monitoring_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_monitoring_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_monitoring_mcp_server.server.os.getenv") - def test_get_monitoring_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_monitoring_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - assert "additional_user_agent" in config - assert ( - isinstance(config["additional_user_agent"], str) - and "/" in config["additional_user_agent"] - ) - # Returned object is client instance - assert srv_client is mock_client.return_value diff --git a/src/oci-monitoring-mcp-server/pyproject.toml b/src/oci-monitoring-mcp-server/pyproject.toml index a5cdae83..d7f12837 100644 --- a/src/oci-monitoring-mcp-server/pyproject.toml +++ b/src/oci-monitoring-mcp-server/pyproject.toml @@ -49,3 +49,10 @@ omit = [ [tool.coverage.report] precision = 2 fail_under = 90 + + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-network-load-balancer-mcp-server/oracle/oci_network_load_balancer_mcp_server/server.py b/src/oci-network-load-balancer-mcp-server/oracle/oci_network_load_balancer_mcp_server/server.py index 14626868..ce6def47 100644 --- a/src/oci-network-load-balancer-mcp-server/oracle/oci_network_load_balancer_mcp_server/server.py +++ b/src/oci-network-load-balancer-mcp-server/oracle/oci_network_load_balancer_mcp_server/server.py @@ -10,6 +10,7 @@ import oci from fastmcp import FastMCP +from oracle.mcp_common import with_oci_client from oracle.oci_network_load_balancer_mcp_server.models import ( Backend, BackendSet, @@ -22,34 +23,18 @@ ) from pydantic import Field -from . import __project__, __version__ +from . import __project__ logger = Logger(__name__, level="INFO") mcp = FastMCP(name=__project__) -def get_nlb_client(): - logger.info("entering get_nlb_client") - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - user_agent_name = __project__.split("oracle.", 1)[1].split("-server", 1)[0] - config["additional_user_agent"] = f"{user_agent_name}/{__version__}" - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = os.path.expanduser(config["security_token_file"]) - token = None - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - return oci.network_load_balancer.NetworkLoadBalancerClient(config, signer=signer) - - @mcp.tool( name="list_network_load_balancers", description="Lists the network load balancers from the given compartment", ) +@with_oci_client(oci.network_load_balancer.NetworkLoadBalancerClient) def list_network_load_balancers( compartment_id: str = Field(..., description="The OCID of the compartment"), limit: Optional[int] = Field( @@ -70,15 +55,14 @@ def list_network_load_balancers( None, description="The lifecycle state of the network load balancer to filter on", ), + *, + client: oci.network_load_balancer.NetworkLoadBalancerClient, ) -> list[NetworkLoadBalancer]: nlbs: list[NetworkLoadBalancer] = [] try: - client = get_nlb_client() - - response: oci.response.Response = None has_next_page = True - next_page: str = None + next_page: Optional[str] = None while has_next_page and (limit is None or len(nlbs) < limit): kwargs = { @@ -90,12 +74,14 @@ def list_network_load_balancers( if lifecycle_state is not None: kwargs["lifecycle_state"] = lifecycle_state - response = client.list_network_load_balancers(**kwargs) + response: oci.response.Response = client.list_network_load_balancers( + **kwargs + ) has_next_page = response.has_next_page - next_page = response.next_page if hasattr(response, "next_page") else None + next_page = getattr(response, "next_page", None) - data: list[oci.network_load_balancer.models.NetworkLoadBalancer] = ( - response.data.items + data: list[oci.network_load_balancer.models.NetworkLoadBalancer] = getattr( + response.data, "items", [] ) for d in data: nlbs.append(map_network_load_balancer(d)) @@ -111,14 +97,15 @@ def list_network_load_balancers( @mcp.tool( name="get_network_load_balancer", description="Get network load balancer details" ) +@with_oci_client(oci.network_load_balancer.NetworkLoadBalancerClient) def get_network_load_balancer( network_load_balancer_id: str = Field( ..., description="The OCID of the network load balancer" - ) + ), + *, + client: oci.network_load_balancer.NetworkLoadBalancerClient, ): try: - client = get_nlb_client() - response: oci.response.Response = client.get_network_load_balancer( network_load_balancer_id ) @@ -135,6 +122,7 @@ def get_network_load_balancer( name="list_network_load_balancer_listeners", description="Lists the listeners from the given network load balancer", ) +@with_oci_client(oci.network_load_balancer.NetworkLoadBalancerClient) def list_listeners( network_load_balancer_id: str = Field( ..., description="The OCID of the network load balancer to list listeners from" @@ -144,15 +132,14 @@ def list_listeners( description="The maximum amount of listeners to return. If None, there is no limit.", ge=1, ), + *, + client: oci.network_load_balancer.NetworkLoadBalancerClient, ) -> list[Listener]: listeners: list[Listener] = [] try: - client = get_nlb_client() - - response: oci.response.Response = None has_next_page = True - next_page: str = None + next_page: Optional[str] = None while has_next_page and (limit is None or len(listeners) < limit): kwargs = { @@ -161,11 +148,13 @@ def list_listeners( "limit": limit, } - response = client.list_listeners(**kwargs) + response: oci.response.Response = client.list_listeners(**kwargs) has_next_page = response.has_next_page - next_page = response.next_page if hasattr(response, "next_page") else None + next_page = getattr(response, "next_page", None) - data: list[oci.network_load_balancer.models.Listener] = response.data.items + data: list[oci.network_load_balancer.models.Listener] = getattr( + response.data, "items", [] + ) for d in data: listeners.append(map_listener(d)) @@ -182,16 +171,17 @@ def list_listeners( description="Gets the listener with the given listener name" "from the given network load balancer", ) +@with_oci_client(oci.network_load_balancer.NetworkLoadBalancerClient) def get_listener( network_load_balancer_id: str = Field( ..., description="The OCID of the network load balancer to get the listener from", ), listener_name: str = Field(..., description="The name of the listener"), + *, + client: oci.network_load_balancer.NetworkLoadBalancerClient, ): try: - client = get_nlb_client() - response: oci.response.Response = client.get_listener( network_load_balancer_id, listener_name ) @@ -208,6 +198,7 @@ def get_listener( name="list_network_load_balancer_backend_sets", description="Lists the backend sets from the given network load balancer", ) +@with_oci_client(oci.network_load_balancer.NetworkLoadBalancerClient) def list_backend_sets( network_load_balancer_id: str = Field( ..., @@ -218,15 +209,14 @@ def list_backend_sets( description="The maximum amount of backend sets to return. If None, there is no limit.", ge=1, ), + *, + client: oci.network_load_balancer.NetworkLoadBalancerClient, ) -> list[BackendSet]: backend_sets: list[BackendSet] = [] try: - client = get_nlb_client() - - response: oci.response.Response = None has_next_page = True - next_page: str = None + next_page: Optional[str] = None while has_next_page and (limit is None or len(backend_sets) < limit): kwargs = { @@ -235,12 +225,12 @@ def list_backend_sets( "limit": limit, } - response = client.list_backend_sets(**kwargs) + response: oci.response.Response = client.list_backend_sets(**kwargs) has_next_page = response.has_next_page - next_page = response.next_page if hasattr(response, "next_page") else None + next_page = getattr(response, "next_page", None) - data: list[oci.network_load_balancer.models.BackendSet] = ( - response.data.items + data: list[oci.network_load_balancer.models.BackendSet] = getattr( + response.data, "items", [] ) for d in data: backend_sets.append(map_backend_set(d)) @@ -258,16 +248,17 @@ def list_backend_sets( description="Gets the backend set with the given backend set name" "from the given network load balancer", ) +@with_oci_client(oci.network_load_balancer.NetworkLoadBalancerClient) def get_backend_set( network_load_balancer_id: str = Field( ..., description="The OCID of the network load balancer to get the backend set from", ), backend_set_name: str = Field(..., description="The name of the backend set"), + *, + client: oci.network_load_balancer.NetworkLoadBalancerClient, ): try: - client = get_nlb_client() - response: oci.response.Response = client.get_backend_set( network_load_balancer_id, backend_set_name ) @@ -284,6 +275,7 @@ def get_backend_set( name="list_network_load_balancer_backends", description="Lists the backends from the given backend set and network load balancer", ) +@with_oci_client(oci.network_load_balancer.NetworkLoadBalancerClient) def list_backends( network_load_balancer_id: str = Field( ..., @@ -297,15 +289,14 @@ def list_backends( description="The maximum amount of backends to return. If None, there is no limit.", ge=1, ), + *, + client: oci.network_load_balancer.NetworkLoadBalancerClient, ) -> list[Backend]: backends: list[Backend] = [] try: - client = get_nlb_client() - - response: oci.response.Response = None has_next_page = True - next_page: str = None + next_page: Optional[str] = None while has_next_page and (limit is None or len(backends) < limit): kwargs = { @@ -315,11 +306,15 @@ def list_backends( "limit": limit, } - response = client.list_backends(**kwargs) + response: oci.response.Response = client.list_backends(**kwargs) has_next_page = response.has_next_page - next_page = response.next_page if hasattr(response, "next_page") else None + next_page = getattr(response, "next_page", None) - data: list[oci.network_load_balancer.models.Backend] = response.data.items + data: list[oci.network_load_balancer.models.Backend] = getattr( + response.data, + "items", + [], + ) for d in data: backends.append(map_backend(d)) @@ -336,6 +331,7 @@ def list_backends( description="Gets the backend with the given backend name" "from the given backend set and network load balancer", ) +@with_oci_client(oci.network_load_balancer.NetworkLoadBalancerClient) def get_backend( network_load_balancer_id: str = Field( ..., description="The OCID of the network load balancer to get the backend from" @@ -344,10 +340,10 @@ def get_backend( ..., description="The name of the backend set to get the backend from" ), backend_name: str = Field(..., description="The name of the backend"), + *, + client: oci.network_load_balancer.NetworkLoadBalancerClient, ): try: - client = get_nlb_client() - response: oci.response.Response = client.get_backend( network_load_balancer_id, backend_set_name, backend_name ) diff --git a/src/oci-network-load-balancer-mcp-server/oracle/oci_network_load_balancer_mcp_server/tests/test_network_load_balancer_tools.py b/src/oci-network-load-balancer-mcp-server/oracle/oci_network_load_balancer_mcp_server/tests/test_network_load_balancer_tools.py index 2c23bfcb..8dd495c1 100644 --- a/src/oci-network-load-balancer-mcp-server/oracle/oci_network_load_balancer_mcp_server/tests/test_network_load_balancer_tools.py +++ b/src/oci-network-load-balancer-mcp-server/oracle/oci_network_load_balancer_mcp_server/tests/test_network_load_balancer_tools.py @@ -4,7 +4,7 @@ https://oss.oracle.com/licenses/upl. """ -from unittest.mock import MagicMock, create_autospec, mock_open, patch +from unittest.mock import MagicMock, create_autospec, patch import oci import pytest @@ -14,12 +14,17 @@ from oracle.oci_network_load_balancer_mcp_server.server import mcp +@pytest.fixture +def mock_nlb_client(): + with patch("oracle.mcp_common.helpers._create_oci_client") as mock_create_client: + client = MagicMock() + mock_create_client.return_value = client + yield client + + class TestNlbTools: @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_list_nlbs(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_list_nlbs(self, mock_nlb_client): mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = ( @@ -43,24 +48,21 @@ async def test_list_nlbs(self, mock_get_client): ) mock_list_response.has_next_page = False mock_list_response.next_page = None - mock_client.list_network_load_balancers.return_value = mock_list_response + mock_nlb_client.list_network_load_balancers.return_value = mock_list_response async with Client(mcp) as client: - result = ( - await client.call_tool( - "list_network_load_balancers", - {"compartment_id": "test_compartment"}, - ) - ).structured_content["result"] + response = await client.call_tool( + "list_network_load_balancers", + {"compartment_id": "test_compartment"}, + ) + assert response.structured_content is not None + result = response.structured_content["result"] assert len(result) == 1 assert result[0]["id"] == "nlb1" @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_list_nlbs_pagination(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_list_nlbs_pagination(self, mock_nlb_client): first = create_autospec(oci.response.Response) first.data = oci.network_load_balancer.models.NetworkLoadBalancerCollection( @@ -79,23 +81,20 @@ async def test_list_nlbs_pagination(self, mock_get_client): second.has_next_page = False second.next_page = None - mock_client.list_network_load_balancers.side_effect = [first, second] + mock_nlb_client.list_network_load_balancers.side_effect = [first, second] async with Client(mcp) as client: - result = ( - await client.call_tool( - "list_network_load_balancers", {"compartment_id": "c1"} - ) - ).structured_content["result"] + response = await client.call_tool( + "list_network_load_balancers", {"compartment_id": "c1"} + ) + assert response.structured_content is not None + result = response.structured_content["result"] assert [n["id"] for n in result] == ["n1", "n2", "n3"] @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_list_nlbs_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.list_network_load_balancers.side_effect = Exception("boom") + async def test_list_nlbs_error(self, mock_nlb_client): + mock_nlb_client.list_network_load_balancers.side_effect = Exception("boom") async with Client(mcp) as client: with pytest.raises(ToolError): @@ -104,10 +103,7 @@ async def test_list_nlbs_error(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_list_listeners(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_list_listeners(self, mock_nlb_client): mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = oci.network_load_balancer.models.ListenerCollection( @@ -123,24 +119,21 @@ async def test_list_listeners(self, mock_get_client): ) mock_list_response.has_next_page = False mock_list_response.next_page = None - mock_client.list_listeners.return_value = mock_list_response + mock_nlb_client.list_listeners.return_value = mock_list_response async with Client(mcp) as client: - result = ( - await client.call_tool( - "list_network_load_balancer_listeners", - {"network_load_balancer_id": "test_nlb"}, - ) - ).structured_content["result"] + response = await client.call_tool( + "list_network_load_balancer_listeners", + {"network_load_balancer_id": "test_nlb"}, + ) + assert response.structured_content is not None + result = response.structured_content["result"] assert len(result) == 1 assert result[0]["name"] == "Listener 1" @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_list_listeners_pagination(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_list_listeners_pagination(self, mock_nlb_client): first = create_autospec(oci.response.Response) first.data = oci.network_load_balancer.models.ListenerCollection( @@ -159,15 +152,15 @@ async def test_list_listeners_pagination(self, mock_get_client): second.has_next_page = False second.next_page = None - mock_client.list_listeners.side_effect = [first, second] + mock_nlb_client.list_listeners.side_effect = [first, second] async with Client(mcp) as client: - result = ( - await client.call_tool( - "list_network_load_balancer_listeners", - {"network_load_balancer_id": "nlb1"}, - ) - ).structured_content["result"] + response = await client.call_tool( + "list_network_load_balancer_listeners", + {"network_load_balancer_id": "nlb1"}, + ) + assert response.structured_content is not None + result = response.structured_content["result"] assert [listener["name"] for listener in result] == [ "listener1", @@ -176,11 +169,8 @@ async def test_list_listeners_pagination(self, mock_get_client): ] @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_list_listeners_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.list_listeners.side_effect = Exception("fail list") + async def test_list_listeners_error(self, mock_nlb_client): + mock_nlb_client.list_listeners.side_effect = Exception("fail list") async with Client(mcp) as client: with pytest.raises(ToolError): @@ -190,10 +180,7 @@ async def test_list_listeners_error(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_list_backend_sets(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_list_backend_sets(self, mock_nlb_client): mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = oci.network_load_balancer.models.BackendSetCollection( @@ -209,24 +196,21 @@ async def test_list_backend_sets(self, mock_get_client): ) mock_list_response.has_next_page = False mock_list_response.next_page = None - mock_client.list_backend_sets.return_value = mock_list_response + mock_nlb_client.list_backend_sets.return_value = mock_list_response async with Client(mcp) as client: - result = ( - await client.call_tool( - "list_network_load_balancer_backend_sets", - {"network_load_balancer_id": "test_nlb"}, - ) - ).structured_content["result"] + response = await client.call_tool( + "list_network_load_balancer_backend_sets", + {"network_load_balancer_id": "test_nlb"}, + ) + assert response.structured_content is not None + result = response.structured_content["result"] assert len(result) == 1 assert result[0]["name"] == "Backend Set 1" @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_list_backend_sets_pagination(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_list_backend_sets_pagination(self, mock_nlb_client): first = create_autospec(oci.response.Response) first.data = oci.network_load_balancer.models.BackendSetCollection( @@ -245,24 +229,21 @@ async def test_list_backend_sets_pagination(self, mock_get_client): second.has_next_page = False second.next_page = None - mock_client.list_backend_sets.side_effect = [first, second] + mock_nlb_client.list_backend_sets.side_effect = [first, second] async with Client(mcp) as client: - result = ( - await client.call_tool( - "list_network_load_balancer_backend_sets", - {"network_load_balancer_id": "nlb1"}, - ) - ).structured_content["result"] + response = await client.call_tool( + "list_network_load_balancer_backend_sets", + {"network_load_balancer_id": "nlb1"}, + ) + assert response.structured_content is not None + result = response.structured_content["result"] assert [b["name"] for b in result] == ["bs1", "bs2", "bs3"] @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_list_backend_sets_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.list_backend_sets.side_effect = Exception("fail bs") + async def test_list_backend_sets_error(self, mock_nlb_client): + mock_nlb_client.list_backend_sets.side_effect = Exception("fail bs") async with Client(mcp) as client: with pytest.raises(ToolError): @@ -272,10 +253,7 @@ async def test_list_backend_sets_error(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_list_backends(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_list_backends(self, mock_nlb_client): mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = oci.network_load_balancer.models.BackendCollection( @@ -294,27 +272,24 @@ async def test_list_backends(self, mock_get_client): ) mock_list_response.has_next_page = False mock_list_response.next_page = None - mock_client.list_backends.return_value = mock_list_response + mock_nlb_client.list_backends.return_value = mock_list_response async with Client(mcp) as client: - result = ( - await client.call_tool( - "list_network_load_balancer_backends", - { - "network_load_balancer_id": "test_nlb", - "backend_set_name": "test_backend_set", - }, - ) - ).structured_content["result"] + response = await client.call_tool( + "list_network_load_balancer_backends", + { + "network_load_balancer_id": "test_nlb", + "backend_set_name": "test_backend_set", + }, + ) + assert response.structured_content is not None + result = response.structured_content["result"] assert len(result) == 1 assert result[0]["name"] == "Backend 1" @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_list_backends_pagination(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_list_backends_pagination(self, mock_nlb_client): first = create_autospec(oci.response.Response) first.data = oci.network_load_balancer.models.BackendCollection( @@ -333,27 +308,24 @@ async def test_list_backends_pagination(self, mock_get_client): second.has_next_page = False second.next_page = None - mock_client.list_backends.side_effect = [first, second] + mock_nlb_client.list_backends.side_effect = [first, second] async with Client(mcp) as client: - result = ( - await client.call_tool( - "list_network_load_balancer_backends", - { - "network_load_balancer_id": "nlb1", - "backend_set_name": "bs1", - }, - ) - ).structured_content["result"] + response = await client.call_tool( + "list_network_load_balancer_backends", + { + "network_load_balancer_id": "nlb1", + "backend_set_name": "bs1", + }, + ) + assert response.structured_content is not None + result = response.structured_content["result"] assert [b["name"] for b in result] == ["b1", "b2", "b3"] @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_list_backends_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.list_backends.side_effect = Exception("fail backends") + async def test_list_backends_error(self, mock_nlb_client): + mock_nlb_client.list_backends.side_effect = Exception("fail backends") async with Client(mcp) as client: with pytest.raises(ToolError): @@ -366,31 +338,25 @@ async def test_list_backends_error(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_get_network_load_balancer(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_get_network_load_balancer(self, mock_nlb_client): mock_resp = create_autospec(oci.response.Response) mock_resp.data = oci.network_load_balancer.models.NetworkLoadBalancer( id="nlb1", display_name="nlb" ) - mock_client.get_network_load_balancer.return_value = mock_resp + mock_nlb_client.get_network_load_balancer.return_value = mock_resp async with Client(mcp) as client: - res = ( - await client.call_tool( - "get_network_load_balancer", {"network_load_balancer_id": "nlb1"} - ) - ).structured_content + response = await client.call_tool( + "get_network_load_balancer", {"network_load_balancer_id": "nlb1"} + ) + assert response.structured_content is not None + res = response.structured_content assert res["id"] == "nlb1" @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_get_network_load_balancer_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.get_network_load_balancer.side_effect = Exception("bad nlb") + async def test_get_network_load_balancer_error(self, mock_nlb_client): + mock_nlb_client.get_network_load_balancer.side_effect = Exception("bad nlb") async with Client(mcp) as client: with pytest.raises(ToolError): @@ -399,30 +365,24 @@ async def test_get_network_load_balancer_error(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_get_listener(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_get_listener(self, mock_nlb_client): mock_resp = create_autospec(oci.response.Response) mock_resp.data = oci.network_load_balancer.models.Listener(name="l1") - mock_client.get_listener.return_value = mock_resp + mock_nlb_client.get_listener.return_value = mock_resp async with Client(mcp) as client: - res = ( - await client.call_tool( - "get_network_load_balancer_listener", - {"network_load_balancer_id": "nlb1", "listener_name": "l1"}, - ) - ).structured_content + response = await client.call_tool( + "get_network_load_balancer_listener", + {"network_load_balancer_id": "nlb1", "listener_name": "l1"}, + ) + assert response.structured_content is not None + res = response.structured_content assert res["name"] == "l1" @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_get_listener_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.get_listener.side_effect = Exception("bad listener") + async def test_get_listener_error(self, mock_nlb_client): + mock_nlb_client.get_listener.side_effect = Exception("bad listener") async with Client(mcp) as client: with pytest.raises(ToolError): @@ -432,33 +392,27 @@ async def test_get_listener_error(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_get_backend_set(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_get_backend_set(self, mock_nlb_client): mock_resp = create_autospec(oci.response.Response) mock_resp.data = oci.network_load_balancer.models.BackendSet(name="bs1") - mock_client.get_backend_set.return_value = mock_resp + mock_nlb_client.get_backend_set.return_value = mock_resp async with Client(mcp) as client: - res = ( - await client.call_tool( - "get_network_load_balancer_backend_set", - { - "network_load_balancer_id": "nlb1", - "backend_set_name": "bs1", - }, - ) - ).structured_content + response = await client.call_tool( + "get_network_load_balancer_backend_set", + { + "network_load_balancer_id": "nlb1", + "backend_set_name": "bs1", + }, + ) + assert response.structured_content is not None + res = response.structured_content assert res["name"] == "bs1" @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_get_backend_set_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.get_backend_set.side_effect = Exception("bad bs") + async def test_get_backend_set_error(self, mock_nlb_client): + mock_nlb_client.get_backend_set.side_effect = Exception("bad bs") async with Client(mcp) as client: with pytest.raises(ToolError): @@ -468,34 +422,28 @@ async def test_get_backend_set_error(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_get_backend(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_get_backend(self, mock_nlb_client): mock_resp = create_autospec(oci.response.Response) mock_resp.data = oci.network_load_balancer.models.Backend(name="b1") - mock_client.get_backend.return_value = mock_resp + mock_nlb_client.get_backend.return_value = mock_resp async with Client(mcp) as client: - res = ( - await client.call_tool( - "get_network_load_balancer_backend", - { - "network_load_balancer_id": "nlb1", - "backend_set_name": "bs1", - "backend_name": "b1", - }, - ) - ).structured_content + response = await client.call_tool( + "get_network_load_balancer_backend", + { + "network_load_balancer_id": "nlb1", + "backend_set_name": "bs1", + "backend_name": "b1", + }, + ) + assert response.structured_content is not None + res = response.structured_content assert res["name"] == "b1" @pytest.mark.asyncio - @patch("oracle.oci_network_load_balancer_mcp_server.server.get_nlb_client") - async def test_get_backend_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.get_backend.side_effect = Exception("bad backend") + async def test_get_backend_error(self, mock_nlb_client): + mock_nlb_client.get_backend.side_effect = Exception("bad backend") async with Client(mcp) as client: with pytest.raises(ToolError): @@ -556,122 +504,3 @@ def test_main_with_only_port(self, mock_getenv, mock_mcp_run): server.main() mock_mcp_run.assert_called_once_with() - - -class TestGetClient: - @patch( - "oracle.oci_network_load_balancer_mcp_server.server.oci" - ".network_load_balancer.NetworkLoadBalancerClient" - ) - @patch( - "oracle.oci_network_load_balancer_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_network_load_balancer_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_network_load_balancer_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_network_load_balancer_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_network_load_balancer_mcp_server.server.os.getenv") - def test_get_nlb_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default - ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - - # Act - result = server.get_nlb_client() - - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - expected_user_agent = f"{server.__project__.split('oracle.', 1)[1].split('-server', 1)[0]}/{server.__version__}" # noqa - assert passed_config.get("additional_user_agent") == expected_user_agent - # And we returned the client instance - assert result == mock_client.return_value - - @patch( - "oracle.oci_network_load_balancer_mcp_server.server.oci" - ".network_load_balancer.NetworkLoadBalancerClient" - ) - @patch( - "oracle.oci_network_load_balancer_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_network_load_balancer_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_network_load_balancer_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_network_load_balancer_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_network_load_balancer_mcp_server.server.os.getenv") - def test_get_nlb_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_nlb_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - assert "additional_user_agent" in config - assert ( - isinstance(config["additional_user_agent"], str) - and "/" in config["additional_user_agent"] - ) - # Returned object is client instance - assert srv_client is mock_client.return_value diff --git a/src/oci-network-load-balancer-mcp-server/pyproject.toml b/src/oci-network-load-balancer-mcp-server/pyproject.toml index 7076644d..ac26b6bd 100644 --- a/src/oci-network-load-balancer-mcp-server/pyproject.toml +++ b/src/oci-network-load-balancer-mcp-server/pyproject.toml @@ -49,3 +49,10 @@ omit = [ [tool.coverage.report] precision = 2 fail_under = 90 + + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-networking-mcp-server/oracle/oci_networking_mcp_server/server.py b/src/oci-networking-mcp-server/oracle/oci_networking_mcp_server/server.py index ffd2e032..7fd385b4 100644 --- a/src/oci-networking-mcp-server/oracle/oci_networking_mcp_server/server.py +++ b/src/oci-networking-mcp-server/oracle/oci_networking_mcp_server/server.py @@ -6,10 +6,11 @@ import os from logging import Logger -from typing import Annotated +from typing import Optional import oci from fastmcp import FastMCP +from oracle.mcp_common import with_oci_client from oracle.oci_networking_mcp_server.models import ( NetworkSecurityGroup, Response, @@ -26,36 +27,25 @@ ) from pydantic import Field -from . import __project__, __version__ +from . import __project__ logger = Logger(__name__, level="INFO") mcp = FastMCP(name=__project__) -def get_networking_client(): - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - user_agent_name = __project__.split("oracle.", 1)[1].split("-server", 1)[0] - config["additional_user_agent"] = f"{user_agent_name}/{__version__}" - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = os.path.expanduser(config["security_token_file"]) - token = None - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - return oci.core.VirtualNetworkClient(config, signer=signer) - - @mcp.tool -def list_vcns(compartment_id: str) -> list[Vcn]: +@with_oci_client(oci.core.VirtualNetworkClient) +def list_vcns( + compartment_id: str = Field( + ..., description="The OCID of the compartment containing the VCNs" + ), + *, + client: oci.core.VirtualNetworkClient, +) -> list[Vcn]: vcns: list[Vcn] = [] try: - client = get_networking_client() - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -79,10 +69,13 @@ def list_vcns(compartment_id: str) -> list[Vcn]: @mcp.tool -def get_vcn(vcn_id: str) -> Vcn: +@with_oci_client(oci.core.VirtualNetworkClient) +def get_vcn( + vcn_id: str = Field(..., description="The OCID of the VCN to retrieve"), + *, + client: oci.core.VirtualNetworkClient, +) -> Vcn: try: - client = get_networking_client() - response: oci.response.Response = client.get_vcn(vcn_id) data: oci.core.models.Vcn = response.data logger.info("Found Vcn") @@ -94,10 +87,13 @@ def get_vcn(vcn_id: str) -> Vcn: @mcp.tool -def delete_vcn(vcn_id: str) -> Response: +@with_oci_client(oci.core.VirtualNetworkClient) +def delete_vcn( + vcn_id: str = Field(..., description="The OCID of the VCN to delete"), + *, + client: oci.core.VirtualNetworkClient, +) -> Response: try: - client = get_networking_client() - response: oci.response.Response = client.delete_vcn(vcn_id) logger.info("Deleted Vcn") return map_response(response) @@ -108,10 +104,17 @@ def delete_vcn(vcn_id: str) -> Response: @mcp.tool -def create_vcn(compartment_id: str, cidr_block: str, display_name: str) -> Vcn: +@with_oci_client(oci.core.VirtualNetworkClient) +def create_vcn( + compartment_id: str = Field( + ..., description="The OCID of the compartment where the VCN will be created" + ), + cidr_block: str = Field(..., description="The IPv4 CIDR block for the VCN"), + display_name: str = Field(..., description="A user-friendly display name"), + *, + client: oci.core.VirtualNetworkClient, +) -> Vcn: try: - client = get_networking_client() - vcn_details = oci.core.models.CreateVcnDetails( compartment_id=compartment_id, cidr_block=cidr_block, @@ -129,12 +132,20 @@ def create_vcn(compartment_id: str, cidr_block: str, display_name: str) -> Vcn: @mcp.tool -def list_subnets(compartment_id: str, vcn_id: str = None) -> list[Subnet]: +@with_oci_client(oci.core.VirtualNetworkClient) +def list_subnets( + compartment_id: str = Field( + ..., description="The OCID of the compartment containing the subnets" + ), + vcn_id: Optional[str] = Field( + None, description="Optional VCN OCID used to filter subnets" + ), + *, + client: oci.core.VirtualNetworkClient, +) -> list[Subnet]: subnets: list[Subnet] = [] try: - client = get_networking_client() - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -160,10 +171,13 @@ def list_subnets(compartment_id: str, vcn_id: str = None) -> list[Subnet]: @mcp.tool -def get_subnet(subnet_id: str) -> Subnet: +@with_oci_client(oci.core.VirtualNetworkClient) +def get_subnet( + subnet_id: str = Field(..., description="The OCID of the subnet to retrieve"), + *, + client: oci.core.VirtualNetworkClient, +) -> Subnet: try: - client = get_networking_client() - response: oci.response.Response = client.get_subnet(subnet_id) data: oci.core.models.Subnet = response.data logger.info("Found Subnet") @@ -175,12 +189,18 @@ def get_subnet(subnet_id: str) -> Subnet: @mcp.tool +@with_oci_client(oci.core.VirtualNetworkClient) def create_subnet( - vcn_id: str, compartment_id: str, cidr_block: str, display_name: str + vcn_id: str = Field(..., description="The OCID of the VCN for the subnet"), + compartment_id: str = Field( + ..., description="The OCID of the compartment for the subnet" + ), + cidr_block: str = Field(..., description="The IPv4 CIDR block for the subnet"), + display_name: str = Field(..., description="A user-friendly display name"), + *, + client: oci.core.VirtualNetworkClient, ) -> Subnet: try: - client = get_networking_client() - subnet_details = oci.core.models.CreateSubnetDetails( compartment_id=compartment_id, vcn_id=vcn_id, @@ -204,15 +224,20 @@ def create_subnet( "If the VCN ID is not provided, then the list includes the security lists from all " "VCNs in the specified compartment.", ) +@with_oci_client(oci.core.VirtualNetworkClient) def list_security_lists( - compartment_id: Annotated[str, "Compartment ocid"], - vcn_id: Annotated[str, "VCN ocid"] = None, + compartment_id: str = Field( + ..., description="The OCID of the compartment containing the security lists" + ), + vcn_id: Optional[str] = Field( + None, description="Optional VCN OCID to limit the results" + ), + *, + client: oci.core.VirtualNetworkClient, ) -> list[SecurityList]: security_lists: list[SecurityList] = [] try: - client = get_networking_client() - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -238,10 +263,13 @@ def list_security_lists( @mcp.tool(name="get_security_list", description="Gets the security list's information.") -def get_security_list(security_list_id: Annotated[str, "security list id"]): +@with_oci_client(oci.core.VirtualNetworkClient) +def get_security_list( + security_list_id: str = Field(..., description="The OCID of the security list"), + *, + client: oci.core.VirtualNetworkClient, +): try: - client = get_networking_client() - response: oci.response.Response = client.get_security_list(security_list_id) data: oci.core.models.Subnet = response.data logger.info("Found Security List") @@ -258,16 +286,23 @@ def get_security_list(security_list_id: Annotated[str, "security list id"]): "a compartmentId, but not both. If you specify a vlanId, all other parameters are " "ignored.", ) +@with_oci_client(oci.core.VirtualNetworkClient) def list_network_security_groups( - compartment_id: Annotated[str, "compartment ocid"], - vlan_id: Annotated[str, "vlan ocid"] = None, - vcn_id: Annotated[str, "vcn ocid"] = None, + compartment_id: Optional[str] = Field( + None, description="The OCID of the compartment containing the NSGs" + ), + vlan_id: Optional[str] = Field( + None, description="Optional VLAN OCID to filter the results" + ), + vcn_id: Optional[str] = Field( + None, description="Optional VCN OCID to filter the results" + ), + *, + client: oci.core.VirtualNetworkClient, ) -> list[NetworkSecurityGroup]: nsgs: list[NetworkSecurityGroup] = [] try: - client = get_networking_client() - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -298,12 +333,15 @@ def list_network_security_groups( @mcp.tool( description="Gets the specified network security group's information.", ) +@with_oci_client(oci.core.VirtualNetworkClient) def get_network_security_group( - network_security_group_id: Annotated[str, "nsg id"], + network_security_group_id: str = Field( + ..., description="The OCID of the network security group" + ), + *, + client: oci.core.VirtualNetworkClient, ): try: - client = get_networking_client() - response: oci.response.Response = client.get_network_security_group( network_security_group_id ) @@ -317,10 +355,13 @@ def get_network_security_group( @mcp.tool(description="Get Vnic with a given OCID") -def get_vnic(vnic_id: str = Field(..., description="The OCID of the vnic")) -> Vnic: +@with_oci_client(oci.core.VirtualNetworkClient) +def get_vnic( + vnic_id: str = Field(..., description="The OCID of the VNIC"), + *, + client: oci.core.VirtualNetworkClient, +) -> Vnic: try: - client = get_networking_client() - response: oci.response.Response = client.get_vnic(vnic_id=vnic_id) data: oci.core.models.Vnic = response.data logger.info("Found Vnic") diff --git a/src/oci-networking-mcp-server/oracle/oci_networking_mcp_server/tests/test_networking_tools.py b/src/oci-networking-mcp-server/oracle/oci_networking_mcp_server/tests/test_networking_tools.py index aa7ff47c..4fa4f4f7 100644 --- a/src/oci-networking-mcp-server/oracle/oci_networking_mcp_server/tests/test_networking_tools.py +++ b/src/oci-networking-mcp-server/oracle/oci_networking_mcp_server/tests/test_networking_tools.py @@ -4,22 +4,29 @@ https://oss.oracle.com/licenses/upl. """ -from unittest.mock import MagicMock, create_autospec, mock_open, patch +from unittest.mock import MagicMock, create_autospec, patch import oci +import oracle.oci_networking_mcp_server.server as server import pytest from fastmcp import Client from fastmcp.exceptions import ToolError -from oracle.oci_networking_mcp_server import server from oracle.oci_networking_mcp_server.server import mcp +@pytest.fixture +def mock_networking_client(): + client = MagicMock() + with patch( + "oracle.mcp_common.helpers._create_oci_client", + return_value=client, + ): + yield client + + class TestNetworkingTools: @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_list_vcns(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_list_vcns(self, mock_networking_client): mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = [ @@ -31,25 +38,24 @@ async def test_list_vcns(self, mock_get_client): ] mock_list_response.has_next_page = False mock_list_response.next_page = None - mock_client.list_vcns.return_value = mock_list_response + mock_networking_client.list_vcns.return_value = mock_list_response async with Client(mcp) as client: - call_tool_result = await client.call_tool( + call_result = await client.call_tool( "list_vcns", { "compartment_id": "compartment1", }, ) - result = call_tool_result.structured_content["result"] + result = call_result.structured_content + if isinstance(result, dict) and "result" in result: + result = result["result"] assert len(result) == 1 assert result[0]["id"] == "vcn1" @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_list_vcns_pagination(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_list_vcns_pagination(self, mock_networking_client): first = create_autospec(oci.response.Response) first.data = [oci.core.models.Vcn(id="v1"), oci.core.models.Vcn(id="v2")] @@ -61,95 +67,81 @@ async def test_list_vcns_pagination(self, mock_get_client): second.has_next_page = False second.next_page = None - mock_client.list_vcns.side_effect = [first, second] + mock_networking_client.list_vcns.side_effect = [first, second] async with Client(mcp) as client: - result = ( - await client.call_tool("list_vcns", {"compartment_id": "c1"}) - ).structured_content["result"] + call_result = await client.call_tool("list_vcns", {"compartment_id": "c1"}) + result = call_result.structured_content + if isinstance(result, dict) and "result" in result: + result = result["result"] assert [v["id"] for v in result] == ["v1", "v2", "v3"] @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_get_vcn_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.get_vcn.side_effect = Exception("boom") + async def test_get_vcn_error(self, mock_networking_client): + mock_networking_client.get_vcn.side_effect = Exception("boom") async with Client(mcp) as client: with pytest.raises(ToolError): await client.call_tool("get_vcn", {"vcn_id": "bad"}) @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_get_vcn(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_get_vcn(self, mock_networking_client): mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.core.models.Vcn( id="vcn1", display_name="VCN 1", lifecycle_state="AVAILABLE" ) - mock_client.get_vcn.return_value = mock_get_response + mock_networking_client.get_vcn.return_value = mock_get_response async with Client(mcp) as client: - call_tool_result = await client.call_tool( + call_result = await client.call_tool( "get_vcn", { "vcn_id": "vcn1", }, ) - result = call_tool_result.structured_content + result = call_result.structured_content assert result["id"] == "vcn1" @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_delete_vcn(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_delete_vcn(self, mock_networking_client): mock_delete_response = create_autospec(oci.response.Response) mock_delete_response.status = 204 - mock_client.delete_vcn.return_value = mock_delete_response + mock_networking_client.delete_vcn.return_value = mock_delete_response async with Client(mcp) as client: - call_tool_result = await client.call_tool( + call_result = await client.call_tool( "delete_vcn", { "vcn_id": "vcn1", }, ) - result = call_tool_result.structured_content + result = call_result.structured_content assert result["status"] == 204 @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_delete_vcn_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.delete_vcn.side_effect = Exception("delete vcn failed") + async def test_delete_vcn_error(self, mock_networking_client): + mock_networking_client.delete_vcn.side_effect = Exception("delete vcn failed") async with Client(mcp) as client: with pytest.raises(ToolError): await client.call_tool("delete_vcn", {"vcn_id": "bad"}) @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_create_vcn(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_create_vcn(self, mock_networking_client): mock_create_response = create_autospec(oci.response.Response) mock_create_response.data = oci.core.models.Vcn( id="vcn1", display_name="VCN 1", lifecycle_state="PROVISIONING" ) - mock_client.create_vcn.return_value = mock_create_response + mock_networking_client.create_vcn.return_value = mock_create_response async with Client(mcp) as client: - call_tool_result = await client.call_tool( + call_result = await client.call_tool( "create_vcn", { "compartment_id": "compartment1", @@ -157,16 +149,13 @@ async def test_create_vcn(self, mock_get_client): "display_name": "VCN 1", }, ) - result = call_tool_result.structured_content + result = call_result.structured_content assert result["id"] == "vcn1" @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_create_vcn_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.create_vcn.side_effect = Exception("create vcn failed") + async def test_create_vcn_error(self, mock_networking_client): + mock_networking_client.create_vcn.side_effect = Exception("create vcn failed") async with Client(mcp) as client: with pytest.raises(ToolError): @@ -180,10 +169,7 @@ async def test_create_vcn_error(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_list_subnets(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_list_subnets(self, mock_networking_client): mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = [ @@ -196,26 +182,25 @@ async def test_list_subnets(self, mock_get_client): ] mock_list_response.has_next_page = False mock_list_response.next_page = None - mock_client.list_subnets.return_value = mock_list_response + mock_networking_client.list_subnets.return_value = mock_list_response async with Client(mcp) as client: - call_tool_result = await client.call_tool( + call_result = await client.call_tool( "list_subnets", { "compartment_id": "compartment1", "vcn_id": "vcn1", }, ) - result = call_tool_result.structured_content["result"] + result = call_result.structured_content + if isinstance(result, dict) and "result" in result: + result = result["result"] assert len(result) == 1 assert result[0]["id"] == "subnet1" @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_list_subnets_pagination(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_list_subnets_pagination(self, mock_networking_client): first = create_autospec(oci.response.Response) first.data = [oci.core.models.Subnet(id="s1"), oci.core.models.Subnet(id="s2")] @@ -227,22 +212,20 @@ async def test_list_subnets_pagination(self, mock_get_client): second.has_next_page = False second.next_page = None - mock_client.list_subnets.side_effect = [first, second] + mock_networking_client.list_subnets.side_effect = [first, second] async with Client(mcp) as client: - result = ( - await client.call_tool( - "list_subnets", {"compartment_id": "c1", "vcn_id": "v1"} - ) - ).structured_content["result"] + call_result = await client.call_tool( + "list_subnets", {"compartment_id": "c1", "vcn_id": "v1"} + ) + result = call_result.structured_content + if isinstance(result, dict) and "result" in result: + result = result["result"] assert [s["id"] for s in result] == ["s1", "s2", "s3"] @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_get_subnet(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_get_subnet(self, mock_networking_client): mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.core.models.Subnet( @@ -251,44 +234,38 @@ async def test_get_subnet(self, mock_get_client): display_name="Subnet 1", lifecycle_state="AVAILABLE", ) - mock_client.get_subnet.return_value = mock_get_response + mock_networking_client.get_subnet.return_value = mock_get_response async with Client(mcp) as client: - call_tool_result = await client.call_tool( + call_result = await client.call_tool( "get_subnet", { "subnet_id": "subnet1", }, ) - result = call_tool_result.structured_content + result = call_result.structured_content assert result["id"] == "subnet1" @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_get_subnet_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.get_subnet.side_effect = Exception("bad subnet") + async def test_get_subnet_error(self, mock_networking_client): + mock_networking_client.get_subnet.side_effect = Exception("bad subnet") async with Client(mcp) as client: with pytest.raises(ToolError): await client.call_tool("get_subnet", {"subnet_id": "bad"}) @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_create_subnet(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_create_subnet(self, mock_networking_client): mock_create_response = create_autospec(oci.response.Response) mock_create_response.data = oci.core.models.Subnet( id="subnet1", display_name="Subnet 1", lifecycle_state="PROVISIONING" ) - mock_client.create_subnet.return_value = mock_create_response + mock_networking_client.create_subnet.return_value = mock_create_response async with Client(mcp) as client: - call_tool_result = await client.call_tool( + call_result = await client.call_tool( "create_subnet", { "vcn_id": "vcn1", @@ -297,16 +274,15 @@ async def test_create_subnet(self, mock_get_client): "display_name": "Subnet 1", }, ) - result = call_tool_result.structured_content + result = call_result.structured_content assert result["id"] == "subnet1" @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_create_subnet_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.create_subnet.side_effect = Exception("create subnet failed") + async def test_create_subnet_error(self, mock_networking_client): + mock_networking_client.create_subnet.side_effect = Exception( + "create subnet failed" + ) async with Client(mcp) as client: with pytest.raises(ToolError): @@ -321,10 +297,7 @@ async def test_create_subnet_error(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_list_security_lists(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_list_security_lists(self, mock_networking_client): mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = [ @@ -336,27 +309,28 @@ async def test_list_security_lists(self, mock_get_client): ] mock_list_response.has_next_page = False mock_list_response.next_page = None - mock_client.list_security_lists.return_value = mock_list_response + mock_networking_client.list_security_lists.return_value = mock_list_response async with Client(mcp) as client: - call_tool_result = await client.call_tool( + call_result = await client.call_tool( "list_security_lists", { "compartment_id": "compartment1", "vcn_id": "vcn1", }, ) - result = call_tool_result.structured_content["result"] + result = call_result.structured_content + if isinstance(result, dict) and "result" in result: + result = result["result"] assert len(result) == 1 assert result[0]["id"] == "securitylist1" @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_list_security_lists_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.list_security_lists.side_effect = Exception("sl list fail") + async def test_list_security_lists_error(self, mock_networking_client): + mock_networking_client.list_security_lists.side_effect = Exception( + "sl list fail" + ) async with Client(mcp) as client: with pytest.raises(ToolError): @@ -366,10 +340,7 @@ async def test_list_security_lists_error(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_get_security_list(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_get_security_list(self, mock_networking_client): mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.core.models.SecurityList( @@ -377,35 +348,29 @@ async def test_get_security_list(self, mock_get_client): display_name="Security List 1", lifecycle_state="AVAILABLE", ) - mock_client.get_security_list.return_value = mock_get_response + mock_networking_client.get_security_list.return_value = mock_get_response async with Client(mcp) as client: - call_tool_result = await client.call_tool( + call_result = await client.call_tool( "get_security_list", { "security_list_id": "securitylist1", }, ) - result = call_tool_result.structured_content + result = call_result.structured_content assert result["id"] == "securitylist1" @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_get_security_list_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.get_security_list.side_effect = Exception("bad sl") + async def test_get_security_list_error(self, mock_networking_client): + mock_networking_client.get_security_list.side_effect = Exception("bad sl") async with Client(mcp) as client: with pytest.raises(ToolError): await client.call_tool("get_security_list", {"security_list_id": "bad"}) @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_list_network_security_groups(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_list_network_security_groups(self, mock_networking_client): mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = [ @@ -417,27 +382,28 @@ async def test_list_network_security_groups(self, mock_get_client): ] mock_list_response.has_next_page = False mock_list_response.next_page = None - mock_client.list_network_security_groups.return_value = mock_list_response + mock_networking_client.list_network_security_groups.return_value = ( + mock_list_response + ) async with Client(mcp) as client: - call_tool_result = await client.call_tool( + call_result = await client.call_tool( "list_network_security_groups", { "compartment_id": "compartment1", "vcn_id": "vcn1", }, ) - result = call_tool_result.structured_content["result"] + result = call_result.structured_content + if isinstance(result, dict) and "result" in result: + result = result["result"] assert len(result) == 1 assert result[0]["id"] == "nsg1" @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_list_network_security_groups_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.list_network_security_groups.side_effect = Exception( + async def test_list_network_security_groups_error(self, mock_networking_client): + mock_networking_client.list_network_security_groups.side_effect = Exception( "nsg list fail" ) @@ -449,10 +415,7 @@ async def test_list_network_security_groups_error(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_get_network_security_group(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_get_network_security_group(self, mock_networking_client): mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.core.models.NetworkSecurityGroup( @@ -460,41 +423,37 @@ async def test_get_network_security_group(self, mock_get_client): display_name="Nsg 1", lifecycle_state="AVAILABLE", ) - mock_client.get_network_security_group.return_value = mock_get_response + mock_networking_client.get_network_security_group.return_value = ( + mock_get_response + ) async with Client(mcp) as client: - call_tool_result = await client.call_tool( + call_result = await client.call_tool( "get_network_security_group", { "network_security_group_id": "nsg1", }, ) - result = call_tool_result.structured_content + result = call_result.structured_content assert result["id"] == "nsg1" @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_get_vnic_error(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client - mock_client.get_vnic.side_effect = Exception("vnic boom") + async def test_get_vnic_error(self, mock_networking_client): + mock_networking_client.get_vnic.side_effect = Exception("vnic boom") async with Client(mcp) as client: with pytest.raises(ToolError): await client.call_tool("get_vnic", {"vnic_id": "bad"}) @pytest.mark.asyncio - @patch("oracle.oci_networking_mcp_server.server.get_networking_client") - async def test_get_vnic(self, mock_get_client): - mock_client = MagicMock() - mock_get_client.return_value = mock_client + async def test_get_vnic(self, mock_networking_client): mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.core.models.Vnic( id="vnic1", display_name="VNIC 1", lifecycle_state="AVAILABLE" ) - mock_client.get_vnic.return_value = mock_get_response + mock_networking_client.get_vnic.return_value = mock_get_response async with Client(mcp) as client: # Expect ToolError due to schema validation issue in installed package @@ -553,116 +512,3 @@ def test_main_with_only_port(self, mock_getenv, mock_mcp_run): server.main() mock_mcp_run.assert_called_once_with() - - -class TestGetClient: - @patch("oracle.oci_networking_mcp_server.server.oci.core.VirtualNetworkClient") - @patch( - "oracle.oci_networking_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_networking_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_networking_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_networking_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_networking_mcp_server.server.os.getenv") - def test_get_networking_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default - ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - - # Act - result = server.get_networking_client() - - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - expected_user_agent = f"{server.__project__.split('oracle.', 1)[1].split('-server', 1)[0]}/{server.__version__}" # noqa - assert passed_config.get("additional_user_agent") == expected_user_agent - # And we returned the client instance - assert result == mock_client.return_value - - @patch("oracle.oci_networking_mcp_server.server.oci.core.VirtualNetworkClient") - @patch( - "oracle.oci_networking_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_networking_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_networking_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_networking_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_networking_mcp_server.server.os.getenv") - def test_get_networking_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_networking_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - assert "additional_user_agent" in config - assert ( - isinstance(config["additional_user_agent"], str) - and "/" in config["additional_user_agent"] - ) - # Returned object is client instance - assert srv_client is mock_client.return_value diff --git a/src/oci-networking-mcp-server/pyproject.toml b/src/oci-networking-mcp-server/pyproject.toml index 734cfdd0..9d3eade9 100644 --- a/src/oci-networking-mcp-server/pyproject.toml +++ b/src/oci-networking-mcp-server/pyproject.toml @@ -51,3 +51,8 @@ precision = 2 fail_under = 90 +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-object-storage-mcp-server/oracle/oci_object_storage_mcp_server/server.py b/src/oci-object-storage-mcp-server/oracle/oci_object_storage_mcp_server/server.py index 5a6026c0..ee2627c5 100644 --- a/src/oci-object-storage-mcp-server/oracle/oci_object_storage_mcp_server/server.py +++ b/src/oci-object-storage-mcp-server/oracle/oci_object_storage_mcp_server/server.py @@ -10,6 +10,7 @@ import oci from fastmcp import FastMCP +from oracle.mcp_common import with_oci_client from oracle.oci_object_storage_mcp_server.models import ( Bucket, BucketSummary, @@ -22,64 +23,55 @@ map_object_version_summary, ) -from . import __project__, __version__ +from . import __project__ logger = Logger(__name__, level="INFO") mcp = FastMCP(name=__project__) -def get_object_storage_client(): - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - user_agent_name = __project__.split("oracle.", 1)[1].split("-server", 1)[0] - config["additional_user_agent"] = f"{user_agent_name}/{__version__}" - - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = os.path.expanduser(config["security_token_file"]) - token = None - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - return oci.object_storage.ObjectStorageClient(config, signer=signer) - - # Object storage namespace -def get_object_storage_namespace(compartment_id: str): - object_storage_client = get_object_storage_client() - namespace = object_storage_client.get_namespace(compartment_id=compartment_id) +@with_oci_client(oci.object_storage.ObjectStorageClient) +def get_object_storage_namespace( + compartment_id: str, *, client: oci.object_storage.ObjectStorageClient +): + namespace = client.get_namespace(compartment_id=compartment_id) return namespace.data @mcp.tool(description="Get the object storage namespace for the tenancy") +@with_oci_client(oci.object_storage.ObjectStorageClient) def get_namespace( compartment_id: Annotated[ str, "The OCID of the compartment." "If compartment id is not provided, use the root compartment id or the tenancy id", ], + *, + client: oci.object_storage.ObjectStorageClient, ): - return get_object_storage_namespace(compartment_id) + return get_object_storage_namespace(compartment_id, client=client) # Buckets @mcp.tool(description="List object storage buckets") +@with_oci_client(oci.object_storage.ObjectStorageClient) def list_buckets( compartment_id: Annotated[ str, "The OCID of the compartment." "If compartment id is not provided, use the root compartment id or the tenancy id", ], + *, + client: oci.object_storage.ObjectStorageClient, ) -> List[BucketSummary]: - object_storage_client = get_object_storage_client() - namespace_name = get_object_storage_namespace(compartment_id) - buckets = object_storage_client.list_buckets(namespace_name, compartment_id).data + namespace_name = get_object_storage_namespace(compartment_id, client=client) + buckets = client.list_buckets(namespace_name, compartment_id).data return [map_bucket_summary(bucket) for bucket in buckets] @mcp.tool(description="Get details for a specific object storage bucket") +@with_oci_client(oci.object_storage.ObjectStorageClient) def get_bucket_details( bucket_name: Annotated[str, "The name of the bucket"], compartment_id: Annotated[ @@ -87,10 +79,11 @@ def get_bucket_details( "The OCID of the compartment." "If compartment id is not provided, use the root compartment id or the tenancy id", ], + *, + client: oci.object_storage.ObjectStorageClient, ) -> Bucket: - object_storage_client = get_object_storage_client() - namespace_name = get_object_storage_namespace(compartment_id) - bucket_details = object_storage_client.get_bucket( + namespace_name = get_object_storage_namespace(compartment_id, client=client) + bucket_details = client.get_bucket( namespace_name, bucket_name, fields=[ @@ -105,6 +98,7 @@ def get_bucket_details( # Objects @mcp.tool(description="List objects in a given object storage bucket") +@with_oci_client(oci.object_storage.ObjectStorageClient) def list_objects( bucket_name: Annotated[str, "The name of the bucket"], compartment_id: Annotated[ @@ -113,10 +107,11 @@ def list_objects( "If compartment id is not provided, use the root compartment id or the tenancy id", ], prefix: Annotated[str, "Optional prefix to filter objects"] = "", + *, + client: oci.object_storage.ObjectStorageClient, ) -> ListObjects: - object_storage_client = get_object_storage_client() - namespace_name = get_object_storage_namespace(compartment_id) - list_objects = object_storage_client.list_objects( + namespace_name = get_object_storage_namespace(compartment_id, client=client) + list_objects = client.list_objects( namespace_name, bucket_name, prefix=prefix, @@ -129,6 +124,7 @@ def list_objects( @mcp.tool(description="List object versions in a given object storage bucket") +@with_oci_client(oci.object_storage.ObjectStorageClient) def list_object_versions( bucket_name: Annotated[str, "The name of the bucket"], compartment_id: Annotated[ @@ -137,10 +133,11 @@ def list_object_versions( "If compartment id is not provided, use the root compartment id or the tenancy id", ], prefix: Annotated[str, "Optional prefix to filter object versions"] = "", + *, + client: oci.object_storage.ObjectStorageClient, ) -> ObjectVersionCollection: - object_storage_client = get_object_storage_client() - namespace_name = get_object_storage_namespace(compartment_id) - list_object_versions = object_storage_client.list_object_versions( + namespace_name = get_object_storage_namespace(compartment_id, client=client) + list_object_versions = client.list_object_versions( namespace_name, bucket_name, prefix=prefix, @@ -156,6 +153,7 @@ def list_object_versions( @mcp.tool(description="Get a specific object from an object storage bucket") +@with_oci_client(oci.object_storage.ObjectStorageClient) def get_object( bucket_name: Annotated[str, "The name of the bucket"], compartment_id: Annotated[ @@ -165,10 +163,11 @@ def get_object( ], object_name: Annotated[str, "The name of the object"], version_id: Annotated[str, "Optional version ID of the object"] = "", + *, + client: oci.object_storage.ObjectStorageClient, ) -> ObjectSummary: - object_storage_client = get_object_storage_client() - namespace_name = get_object_storage_namespace(compartment_id) - obj = object_storage_client.get_object( + namespace_name = get_object_storage_namespace(compartment_id, client=client) + obj = client.get_object( namespace_name, bucket_name, object_name, @@ -179,6 +178,7 @@ def get_object( @mcp.tool(description="Upload an object to an object storage bucket") +@with_oci_client(oci.object_storage.ObjectStorageClient) def upload_object( bucket_name: Annotated[str, "The name of the bucket"], compartment_id: Annotated[ @@ -192,16 +192,15 @@ def upload_object( "Optional name of the object to upload" "If the object name is not provided, use the file name as the object name", ] = "", + *, + client: oci.object_storage.ObjectStorageClient, ): - object_storage_client = get_object_storage_client() - namespace_name = get_object_storage_namespace(compartment_id) + namespace_name = get_object_storage_namespace(compartment_id, client=client) logger.info("Got Namespace: %s", namespace_name) logger.info("Checking file at path: %s", file_path) try: with open(file_path, "rb") as file: - object_storage_client.put_object( - namespace_name, bucket_name, object_name, file - ) + client.put_object(namespace_name, bucket_name, object_name, file) return {"message": "Object uploaded successfully"} except Exception as e: return {"error": str(e)} diff --git a/src/oci-object-storage-mcp-server/oracle/oci_object_storage_mcp_server/tests/test_object_storage_tools.py b/src/oci-object-storage-mcp-server/oracle/oci_object_storage_mcp_server/tests/test_object_storage_tools.py index 591d0542..c0f7a78d 100644 --- a/src/oci-object-storage-mcp-server/oracle/oci_object_storage_mcp_server/tests/test_object_storage_tools.py +++ b/src/oci-object-storage-mcp-server/oracle/oci_object_storage_mcp_server/tests/test_object_storage_tools.py @@ -4,12 +4,11 @@ https://oss.oracle.com/licenses/upl. """ -from unittest.mock import MagicMock, create_autospec, mock_open, patch +from unittest.mock import MagicMock, create_autospec, patch import oci import pytest from fastmcp import Client -from oracle.oci_object_storage_mcp_server import server from oracle.oci_object_storage_mcp_server.models import ( Bucket, BucketSummary, @@ -23,10 +22,10 @@ class TestObjectStorageTools: @pytest.mark.asyncio - @patch("oracle.oci_object_storage_mcp_server.server.get_object_storage_client") - async def test_get_namespace(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_namespace(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_namespace_response = create_autospec(oci.response.Response) mock_namespace_response.data = "test_namespace" @@ -36,29 +35,22 @@ async def test_get_namespace(self, mock_get_client): response = await client.call_tool( "get_namespace", {"compartment_id": "test_compartment"} ) - result = response.content[0].text - - assert result == "test_namespace" + # get_namespace returns unstructured text content only + assert response.content[0].text == "test_namespace" @pytest.mark.asyncio - @patch("oracle.oci_object_storage_mcp_server.server.get_object_storage_client") - async def test_list_buckets(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_buckets(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_namespace_response = create_autospec(oci.response.Response) mock_namespace_response.data = "test_namespace" mock_client.get_namespace.return_value = mock_namespace_response - mock_list_response = create_autospec(oci.response.Response) - mock_list_response.data = [ - BucketSummary( - name="bucket1", - etag="etag1", - time_created="2021-01-01T00:00:00.000Z", - ) - ] - mock_client.list_buckets.return_value = mock_list_response + mock_bucket_response = create_autospec(oci.response.Response) + mock_bucket_response.data = [BucketSummary(name="bucket1")] + mock_client.list_buckets.return_value = mock_bucket_response async with Client(mcp) as client: result = ( @@ -71,25 +63,21 @@ async def test_list_buckets(self, mock_get_client): assert result[0]["name"] == "bucket1" @pytest.mark.asyncio - @patch("oracle.oci_object_storage_mcp_server.server.get_object_storage_client") - async def test_get_bucket_details(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_bucket_details(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_namespace_response = create_autospec(oci.response.Response) mock_namespace_response.data = "test_namespace" mock_client.get_namespace.return_value = mock_namespace_response - mock_get_response = create_autospec(oci.response.Response) - mock_get_response.data = Bucket( + mock_bucket_response = create_autospec(oci.response.Response) + mock_bucket_response.data = Bucket( name="bucket1", - etag="etag1", - time_created="2021-01-01T00:00:00.000Z", approximate_size=100, - approximate_count=10, - auto_tiering="INFREQUENT", ) - mock_client.get_bucket.return_value = mock_get_response + mock_client.get_bucket.return_value = mock_bucket_response async with Client(mcp) as client: result = ( @@ -106,28 +94,26 @@ async def test_get_bucket_details(self, mock_get_client): assert result["approximate_size"] == 100 @pytest.mark.asyncio - @patch("oracle.oci_object_storage_mcp_server.server.get_object_storage_client") - async def test_list_objects(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_objects(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_namespace_response = create_autospec(oci.response.Response) mock_namespace_response.data = "test_namespace" mock_client.get_namespace.return_value = mock_namespace_response - mock_list_response = create_autospec(oci.response.Response) - mock_list_response.data = ListObjects( + mock_list_objects_response = create_autospec(oci.response.Response) + mock_list_objects_response.data = ListObjects( objects=[ ObjectSummary( name="object1", size=100, - time_modified="2021-01-01T00:00:00.000Z", - archival_state="ARCHIVED", storage_tier="STANDARD", ) ] ) - mock_client.list_objects.return_value = mock_list_response + mock_client.list_objects.return_value = mock_list_objects_response async with Client(mcp) as client: result = ( @@ -144,17 +130,16 @@ async def test_list_objects(self, mock_get_client): assert result[0]["name"] == "object1" @pytest.mark.asyncio - @patch("oracle.oci_object_storage_mcp_server.server.get_object_storage_client") - async def test_list_object_versions(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_object_versions(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_namespace_response = create_autospec(oci.response.Response) mock_namespace_response.data = "test_namespace" mock_client.get_namespace.return_value = mock_namespace_response - mock_list_response = create_autospec(oci.response.Response) - mock_list_response.data = ObjectVersionCollection( + mock_list_response = ObjectVersionCollection( items=[ ObjectVersionSummary( name="object1", @@ -164,7 +149,9 @@ async def test_list_object_versions(self, mock_get_client): ) ] ) - mock_client.list_object_versions.return_value = mock_list_response + mock_list_versions_response = create_autospec(oci.response.Response) + mock_list_versions_response.data = mock_list_response + mock_client.list_object_versions.return_value = mock_list_versions_response async with Client(mcp) as client: result = ( @@ -182,18 +169,18 @@ async def test_list_object_versions(self, mock_get_client): assert result["items"][0]["version_id"] == "version_1" @pytest.mark.asyncio - @patch("oracle.oci_object_storage_mcp_server.server.get_object_storage_client") - async def test_get_object(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_object(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_namespace_response = create_autospec(oci.response.Response) mock_namespace_response.data = "test_namespace" mock_client.get_namespace.return_value = mock_namespace_response - mock_get_response = create_autospec(oci.response.Response) - mock_get_response.data = ObjectSummary(name="object1", size=42) - mock_client.get_object.return_value = mock_get_response + mock_get_object_response = create_autospec(oci.response.Response) + mock_get_object_response.data = ObjectSummary(name="object1", size=42) + mock_client.get_object.return_value = mock_get_object_response async with Client(mcp) as client: result = ( @@ -211,10 +198,10 @@ async def test_get_object(self, mock_get_client): assert result["size"] == 42 @pytest.mark.asyncio - @patch("oracle.oci_object_storage_mcp_server.server.get_object_storage_client") - async def test_upload_object_success(self, mock_get_client, tmp_path): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_upload_object_success(self, mock_create_client, tmp_path): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_namespace_response = create_autospec(oci.response.Response) mock_namespace_response.data = "test_namespace" @@ -224,26 +211,26 @@ async def test_upload_object_success(self, mock_get_client, tmp_path): p.write_bytes(b"hello world") async with Client(mcp) as client: - result = ( - await client.call_tool( - "upload_object", - { - "bucket_name": "bucket1", - "compartment_id": "test_compartment", - "file_path": str(p), - "object_name": "file.txt", - }, - ) - ).structured_content + response = await client.call_tool( + "upload_object", + { + "bucket_name": "bucket1", + "compartment_id": "test_compartment", + "file_path": str(p), + "object_name": "file.txt", + }, + ) - assert result["message"] == "Object uploaded successfully" + structured = response.structured_content or {} + structured = structured.get("result", structured) + assert structured["message"] == "Object uploaded successfully" mock_client.put_object.assert_called_once() @pytest.mark.asyncio - @patch("oracle.oci_object_storage_mcp_server.server.get_object_storage_client") - async def test_upload_object_error(self, mock_get_client, tmp_path): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_upload_object_error(self, mock_create_client, tmp_path): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_namespace_response = create_autospec(oci.response.Response) mock_namespace_response.data = "test_namespace" @@ -252,187 +239,20 @@ async def test_upload_object_error(self, mock_get_client, tmp_path): bad_path = tmp_path / "does_not_exist.bin" async with Client(mcp) as client: - result = ( - await client.call_tool( - "upload_object", - { - "bucket_name": "bucket1", - "compartment_id": "test_compartment", - "file_path": str(bad_path), - "object_name": "file.bin", - }, - ) - ).structured_content - - assert "error" in result - assert ( - "No such file" in result["error"] - or "No such file or directory" in result["error"] - ) - - -class TestServer: - @patch("oracle.oci_object_storage_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_host_and_port(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_HOST": "1.2.3.4", - "ORACLE_MCP_PORT": "8888", - } - - mock_getenv.side_effect = lambda x: mock_env.get(x) - - server.main() - mock_mcp_run.assert_called_once_with( - transport="http", - host=mock_env["ORACLE_MCP_HOST"], - port=int(mock_env["ORACLE_MCP_PORT"]), - ) + response = await client.call_tool( + "upload_object", + { + "bucket_name": "bucket1", + "compartment_id": "test_compartment", + "file_path": str(bad_path), + "object_name": "file.bin", + }, + ) - @patch("oracle.oci_object_storage_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_without_host_and_port(self, mock_getenv, mock_mcp_run): - mock_getenv.return_value = None - import oracle.oci_object_storage_mcp_server.server as server - - server.main() - mock_mcp_run.assert_called_once_with() - - @patch("oracle.oci_object_storage_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_only_host(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_HOST": "1.2.3.4", - } - mock_getenv.side_effect = lambda x: mock_env.get(x) - - server.main() - mock_mcp_run.assert_called_once_with() - - @patch("oracle.oci_object_storage_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_only_port(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_PORT": "8888", - } - mock_getenv.side_effect = lambda x: mock_env.get(x) - - server.main() - mock_mcp_run.assert_called_once_with() - - -class TestGetClient: - @patch( - "oracle.oci_object_storage_mcp_server.server.oci.object_storage.ObjectStorageClient" - ) - @patch( - "oracle.oci_object_storage_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_object_storage_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_object_storage_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_object_storage_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_object_storage_mcp_server.server.os.getenv") - def test_get_object_storage_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default - ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - - # Act - result = server.get_object_storage_client() - - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - expected_user_agent = f"{server.__project__.split('oracle.', 1)[1].split('-server', 1)[0]}/{server.__version__}" # noqa - assert passed_config.get("additional_user_agent") == expected_user_agent - # And we returned the client instance - assert result == mock_client.return_value - - @patch( - "oracle.oci_object_storage_mcp_server.server.oci.object_storage.ObjectStorageClient" - ) - @patch( - "oracle.oci_object_storage_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_object_storage_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_object_storage_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_object_storage_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_object_storage_mcp_server.server.os.getenv") - def test_get_object_storage_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_object_storage_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - assert "additional_user_agent" in config + structured = response.structured_content or {} + structured = structured.get("result", structured) + assert "error" in structured assert ( - isinstance(config["additional_user_agent"], str) - and "/" in config["additional_user_agent"] + "No such file" in structured["error"] + or "No such file or directory" in structured["error"] ) - # Returned object is client instance - assert srv_client is mock_client.return_value diff --git a/src/oci-object-storage-mcp-server/pyproject.toml b/src/oci-object-storage-mcp-server/pyproject.toml index c836da62..83be0bfc 100644 --- a/src/oci-object-storage-mcp-server/pyproject.toml +++ b/src/oci-object-storage-mcp-server/pyproject.toml @@ -47,3 +47,10 @@ omit = [ [tool.coverage.report] precision = 2 fail_under = 90 + + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-pricing-mcp-server/pyproject.toml b/src/oci-pricing-mcp-server/pyproject.toml index 8a5af29f..b3715ddd 100644 --- a/src/oci-pricing-mcp-server/pyproject.toml +++ b/src/oci-pricing-mcp-server/pyproject.toml @@ -47,3 +47,10 @@ fix = true [tool.ruff.lint] select = ["E","F","I","UP","B"] ignore = ["E501"] + + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-registry-mcp-server/oracle/oci_registry_mcp_server/server.py b/src/oci-registry-mcp-server/oracle/oci_registry_mcp_server/server.py index ee4e011f..64efeac9 100644 --- a/src/oci-registry-mcp-server/oracle/oci_registry_mcp_server/server.py +++ b/src/oci-registry-mcp-server/oracle/oci_registry_mcp_server/server.py @@ -10,6 +10,7 @@ import oci from fastmcp import FastMCP +from oracle.mcp_common import with_oci_client from oracle.oci_registry_mcp_server.models import ( ContainerRepository, Response, @@ -18,32 +19,15 @@ ) from pydantic import Field -from . import __project__, __version__ +from . import __project__ logger = Logger(__name__, level="INFO") mcp = FastMCP(name=__project__) -def get_ocir_client(): - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - - user_agent_name = __project__.split("oracle.", 1)[1].split("-server", 1)[0] - config["additional_user_agent"] = f"{user_agent_name}/{__version__}" - - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = os.path.expanduser(config["security_token_file"]) - token = None - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - return oci.artifacts.ArtifactsClient(config, signer=signer) - - @mcp.tool(description="List container repositories in the given compartment") +@with_oci_client(oci.artifacts.ArtifactsClient) def list_container_repositories( compartment_id: str = Field(..., description="The OCID of the compartment"), limit: Optional[int] = Field( @@ -51,12 +35,12 @@ def list_container_repositories( description="The maximum amount of conatiner repositories to return. If None, there is no limit.", ge=1, ), + *, + client: oci.artifacts.ArtifactsClient, ) -> list[ContainerRepository]: container_repositories: list[ContainerRepository] = [] try: - client = get_ocir_client() - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -85,12 +69,13 @@ def list_container_repositories( @mcp.tool +@with_oci_client(oci.artifacts.ArtifactsClient) def get_container_repository( - repository_id: str = Field(..., description="The OCID of the container repository") + repository_id: str = Field(..., description="The OCID of the container repository"), + *, + client: oci.artifacts.ArtifactsClient, ) -> ContainerRepository: try: - client = get_ocir_client() - response: oci.response.Response = client.get_container_repository(repository_id) data: oci.artifacts.models.ContainerRepository = response.data logger.info("Found Container Repository") @@ -102,6 +87,7 @@ def get_container_repository( @mcp.tool +@with_oci_client(oci.artifacts.ArtifactsClient) def create_container_repository( compartment_id: str = Field( ..., @@ -119,10 +105,10 @@ def create_container_repository( is_public: bool = Field( False, description="Whether or not the repository is public" ), + *, + client: oci.artifacts.ArtifactsClient, ) -> ContainerRepository: try: - client = get_ocir_client() - create_repository_details = ( oci.artifacts.models.CreateContainerRepositoryDetails( compartment_id=compartment_id, @@ -144,12 +130,13 @@ def create_container_repository( @mcp.tool +@with_oci_client(oci.artifacts.ArtifactsClient) def delete_container_repository( - repository_id: str = Field(..., description="The OCID of the container repository") + repository_id: str = Field(..., description="The OCID of the container repository"), + *, + client: oci.artifacts.ArtifactsClient, ) -> Response: try: - client = get_ocir_client() - response: oci.response.Response = client.delete_container_repository( repository_id ) diff --git a/src/oci-registry-mcp-server/oracle/oci_registry_mcp_server/tests/test_registry_tools.py b/src/oci-registry-mcp-server/oracle/oci_registry_mcp_server/tests/test_registry_tools.py index 9591d877..51e6a2e8 100644 --- a/src/oci-registry-mcp-server/oracle/oci_registry_mcp_server/tests/test_registry_tools.py +++ b/src/oci-registry-mcp-server/oracle/oci_registry_mcp_server/tests/test_registry_tools.py @@ -4,21 +4,20 @@ https://oss.oracle.com/licenses/upl. """ -from unittest.mock import MagicMock, create_autospec, mock_open, patch +from unittest.mock import MagicMock, create_autospec, patch import oci import pytest from fastmcp import Client -from oracle.oci_registry_mcp_server import server from oracle.oci_registry_mcp_server.server import mcp class TestRegistryTools: @pytest.mark.asyncio - @patch("oracle.oci_registry_mcp_server.server.get_ocir_client") - async def test_list_container_repositories(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_container_repositories(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_list_response = create_autospec(oci.response.Response) mock_list_response.data = oci.artifacts.models.ContainerRepositoryCollection( @@ -46,10 +45,10 @@ async def test_list_container_repositories(self, mock_get_client): assert result[0]["display_name"] == "repo1" @pytest.mark.asyncio - @patch("oracle.oci_registry_mcp_server.server.get_ocir_client") - async def test_get_container_repository(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_container_repository(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_get_response = create_autospec(oci.response.Response) mock_get_response.data = oci.artifacts.models.ContainerRepository( @@ -73,10 +72,10 @@ async def test_get_container_repository(self, mock_get_client): assert result["display_name"] == "repo1" @pytest.mark.asyncio - @patch("oracle.oci_registry_mcp_server.server.get_ocir_client") - async def test_create_container_repository(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_create_container_repository(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_create_response = create_autospec(oci.response.Response) mock_create_response.data = oci.artifacts.models.ContainerRepository( @@ -98,10 +97,10 @@ async def test_create_container_repository(self, mock_get_client): assert result["display_name"] == "repo1" @pytest.mark.asyncio - @patch("oracle.oci_registry_mcp_server.server.get_ocir_client") - async def test_delete_container_repository(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_delete_container_repository(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_delete_response = create_autospec(oci.response.Response) mock_delete_response.status = 204 @@ -120,10 +119,10 @@ async def test_delete_container_repository(self, mock_get_client): assert result["status"] == 204 @pytest.mark.asyncio - @patch("oracle.oci_registry_mcp_server.server.get_ocir_client") - async def test_list_container_repositories_raises(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_list_container_repositories_raises(self, mock_create_client): # Cause the tool to raise and ensure the MCP wrapper returns an error payload - mock_get_client.side_effect = ValueError("boom") + mock_create_client.side_effect = ValueError("boom") async with Client(mcp) as client: call_tool_result = await client.call_tool( @@ -139,11 +138,11 @@ async def test_list_container_repositories_raises(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_registry_mcp_server.server.get_ocir_client") - async def test_get_container_repository_raises(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_container_repository_raises(self, mock_create_client): mock_client = MagicMock() mock_client.get_container_repository.side_effect = RuntimeError("oops") - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client async with Client(mcp) as client: res = await client.call_tool( @@ -155,11 +154,11 @@ async def test_get_container_repository_raises(self, mock_get_client): assert any(getattr(b, "text", "").find("oops") != -1 for b in res.content) @pytest.mark.asyncio - @patch("oracle.oci_registry_mcp_server.server.get_ocir_client") - async def test_create_container_repository_raises(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_create_container_repository_raises(self, mock_create_client): mock_client = MagicMock() mock_client.create_container_repository.side_effect = RuntimeError("fail") - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client async with Client(mcp) as client: res = await client.call_tool( @@ -175,11 +174,11 @@ async def test_create_container_repository_raises(self, mock_get_client): assert any(getattr(b, "text", "").find("fail") != -1 for b in res.content) @pytest.mark.asyncio - @patch("oracle.oci_registry_mcp_server.server.get_ocir_client") - async def test_delete_container_repository_raises(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_delete_container_repository_raises(self, mock_create_client): mock_client = MagicMock() mock_client.delete_container_repository.side_effect = RuntimeError("nope") - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client async with Client(mcp) as client: res = await client.call_tool( @@ -242,112 +241,3 @@ def test_main_with_only_port(self, mock_getenv, mock_mcp_run): server.main() mock_mcp_run.assert_called_once_with() - - -class TestGetClient: - @patch("oracle.oci_registry_mcp_server.server.oci.artifacts.ArtifactsClient") - @patch("oracle.oci_registry_mcp_server.server.oci.auth.signers.SecurityTokenSigner") - @patch( - "oracle.oci_registry_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_registry_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_registry_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_registry_mcp_server.server.os.getenv") - def test_get_ocir_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default - ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - - # Act - result = server.get_ocir_client() - - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - expected_user_agent = f"{server.__project__.split('oracle.', 1)[1].split('-server', 1)[0]}/{server.__version__}" # noqa - assert passed_config.get("additional_user_agent") == expected_user_agent - # And we returned the client instance - assert result == mock_client.return_value - - @patch("oracle.oci_registry_mcp_server.server.oci.artifacts.ArtifactsClient") - @patch("oracle.oci_registry_mcp_server.server.oci.auth.signers.SecurityTokenSigner") - @patch( - "oracle.oci_registry_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_registry_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_registry_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_registry_mcp_server.server.os.getenv") - def test_get_ocir_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_ocir_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - assert "additional_user_agent" in config - assert ( - isinstance(config["additional_user_agent"], str) - and "/" in config["additional_user_agent"] - ) - # Returned object is client instance - assert srv_client is mock_client.return_value diff --git a/src/oci-registry-mcp-server/pyproject.toml b/src/oci-registry-mcp-server/pyproject.toml index 1a4cd7f1..d2477474 100644 --- a/src/oci-registry-mcp-server/pyproject.toml +++ b/src/oci-registry-mcp-server/pyproject.toml @@ -49,3 +49,9 @@ omit = [ [tool.coverage.report] precision = 2 fail_under = 90 + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-resource-search-mcp-server/oracle/oci_resource_search_mcp_server/server.py b/src/oci-resource-search-mcp-server/oracle/oci_resource_search_mcp_server/server.py index f35e4afa..588b4718 100644 --- a/src/oci-resource-search-mcp-server/oracle/oci_resource_search_mcp_server/server.py +++ b/src/oci-resource-search-mcp-server/oracle/oci_resource_search_mcp_server/server.py @@ -11,39 +11,22 @@ import oci from fastmcp import FastMCP from oci.resource_search.models import FreeTextSearchDetails, StructuredSearchDetails +from oracle.mcp_common import with_oci_client from oracle.oci_resource_search_mcp_server.models import ( ResourceSummary, map_resource_summary, ) from pydantic import Field -from . import __project__, __version__ +from . import __project__ logger = Logger(__name__, level="INFO") mcp = FastMCP(name=__project__) -def get_search_client(): - logger.info("entering get_search_client") - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - - user_agent_name = __project__.split("oracle.", 1)[1].split("-server", 1)[0] - config["additional_user_agent"] = f"{user_agent_name}/{__version__}" - - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = os.path.expanduser(config["security_token_file"]) - token = None - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - return oci.resource_search.ResourceSearchClient(config, signer=signer) - - @mcp.tool(description="Returns all resources") +@with_oci_client(oci.resource_search.ResourceSearchClient) def list_all_resources( tenant_id: str = Field( ..., @@ -58,12 +41,12 @@ def list_all_resources( description="The maximum amount of resources to return. If None, there is no limit.", ge=1, ), + *, + client: oci.resource_search.ResourceSearchClient, ) -> list[ResourceSummary]: resources: list[ResourceSummary] = [] try: - client = get_search_client() - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -96,6 +79,7 @@ def list_all_resources( @mcp.tool(description="Searches for resources by display name") +@with_oci_client(oci.resource_search.ResourceSearchClient) def search_resources( tenant_id: str = Field( ..., @@ -113,14 +97,12 @@ def search_resources( description="The maximum amount of resources to return. If None, there is no limit.", ge=1, ), + *, + client: oci.resource_search.ResourceSearchClient, ) -> list[ResourceSummary]: resources: list[ResourceSummary] = [] try: - client = get_search_client() - - oci.identity.models.Compartment - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -158,6 +140,7 @@ def search_resources( @mcp.tool( description="Searches for the presence of the search string in all resource fields" ) +@with_oci_client(oci.resource_search.ResourceSearchClient) def search_resources_free_form( tenant_id: str = Field( ..., @@ -170,12 +153,12 @@ def search_resources_free_form( description="The maximum amount of resources to return. If None, there is no limit.", ge=1, ), + *, + client: oci.resource_search.ResourceSearchClient, ) -> list[ResourceSummary]: resources: list[ResourceSummary] = [] try: - client = get_search_client() - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -208,6 +191,7 @@ def search_resources_free_form( @mcp.tool(description="Search for resources by resource type") +@with_oci_client(oci.resource_search.ResourceSearchClient) def search_resources_by_type( tenant_id: str = Field( ..., @@ -228,12 +212,12 @@ def search_resources_by_type( description="The maximum amount of resources to return. If None, there is no limit.", ge=1, ), + *, + client: oci.resource_search.ResourceSearchClient, ) -> list[ResourceSummary]: resources: list[ResourceSummary] = [] try: - client = get_search_client() - response: oci.response.Response = None has_next_page = True next_page: str = None @@ -269,18 +253,19 @@ def search_resources_by_type( @mcp.tool(description="Returns a list of all supported OCI resource types") +@with_oci_client(oci.resource_search.ResourceSearchClient) def list_resource_types( limit: Optional[int] = Field( None, description="The maximum amount of resources to return. If None, there is no limit.", ge=1, ), + *, + client: oci.resource_search.ResourceSearchClient, ) -> list[str]: resource_types: list[str] = [] try: - client = get_search_client() - response: oci.response.Response = None has_next_page = True next_page: str = None diff --git a/src/oci-resource-search-mcp-server/oracle/oci_resource_search_mcp_server/tests/test_resource_search_tools.py b/src/oci-resource-search-mcp-server/oracle/oci_resource_search_mcp_server/tests/test_resource_search_tools.py index cc75b69e..82e4d4a8 100644 --- a/src/oci-resource-search-mcp-server/oracle/oci_resource_search_mcp_server/tests/test_resource_search_tools.py +++ b/src/oci-resource-search-mcp-server/oracle/oci_resource_search_mcp_server/tests/test_resource_search_tools.py @@ -4,19 +4,18 @@ https://oss.oracle.com/licenses/upl. """ -from unittest.mock import MagicMock, create_autospec, mock_open, patch +from unittest.mock import MagicMock, create_autospec, patch import oci import pytest from fastmcp import Client from fastmcp.exceptions import ToolError -from oracle.oci_resource_search_mcp_server import server from oracle.oci_resource_search_mcp_server.server import mcp class TestResourceSearchTools: @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_all_resources(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -53,7 +52,7 @@ async def test_list_all_resources(self, mock_get_client): assert result[0]["identifier"] == "resource1" @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_search_resources(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -91,7 +90,7 @@ async def test_search_resources(self, mock_get_client): assert result[0]["identifier"] == "resource1" @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_search_resources_free_form(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -128,7 +127,7 @@ async def test_search_resources_free_form(self, mock_get_client): assert result[0]["identifier"] == "resource1" @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_search_resources_by_type(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -166,7 +165,7 @@ async def test_search_resources_by_type(self, mock_get_client): assert result[0]["resource_type"] == "dbsystem" @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_resource_types(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -186,7 +185,7 @@ async def test_list_resource_types(self, mock_get_client): assert result == ["instance", "volume"] @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_all_resources_pagination(self, mock_get_client): # Two pages, second response lacks next_page attribute to hit hasattr False branch mock_client = MagicMock() @@ -221,7 +220,7 @@ async def test_list_all_resources_pagination(self, mock_get_client): assert [r["identifier"] for r in result] == ["r1", "r2"] @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_resource_types_no_next_page_attr(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -239,7 +238,7 @@ async def test_list_resource_types_no_next_page_attr(self, mock_get_client): assert result == ["instance"] @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_search_resources_no_next_page_attr(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -265,7 +264,7 @@ async def test_search_resources_no_next_page_attr(self, mock_get_client): assert len(result) == 1 @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_search_resources_free_form_no_next_page_attr(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -287,7 +286,7 @@ async def test_search_resources_free_form_no_next_page_attr(self, mock_get_clien assert len(result) == 1 @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_search_resources_by_type_no_next_page_attr(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -313,7 +312,7 @@ async def test_search_resources_by_type_no_next_page_attr(self, mock_get_client) assert len(result) == 1 @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_all_resources_respects_limit(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -342,7 +341,7 @@ async def test_list_all_resources_respects_limit(self, mock_get_client): mock_client.search_resources.assert_called_once() @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_all_resources_error_path(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -355,7 +354,7 @@ async def test_list_all_resources_error_path(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_search_resources_error_path(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -372,7 +371,7 @@ async def test_search_resources_error_path(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_search_resources_free_form_error_path(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -385,7 +384,7 @@ async def test_search_resources_free_form_error_path(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_search_resources_by_type_error_path(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -402,7 +401,7 @@ async def test_search_resources_by_type_error_path(self, mock_get_client): ) @pytest.mark.asyncio - @patch("oracle.oci_resource_search_mcp_server.server.get_search_client") + @patch("oracle.mcp_common.helpers._create_oci_client") async def test_list_resource_types_error_path(self, mock_get_client): mock_client = MagicMock() mock_get_client.return_value = mock_client @@ -410,171 +409,3 @@ async def test_list_resource_types_error_path(self, mock_get_client): async with Client(mcp) as client: with pytest.raises(ToolError): await client.call_tool("list_resource_types", {}) - - -class TestServer: - @patch("oracle.oci_resource_search_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_host_and_port(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_HOST": "1.2.3.4", - "ORACLE_MCP_PORT": "8888", - } - - mock_getenv.side_effect = lambda x: mock_env.get(x) - - server.main() - mock_mcp_run.assert_called_once_with( - transport="http", - host=mock_env["ORACLE_MCP_HOST"], - port=int(mock_env["ORACLE_MCP_PORT"]), - ) - - @patch("oracle.oci_resource_search_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_without_host_and_port(self, mock_getenv, mock_mcp_run): - mock_getenv.return_value = None - - server.main() - mock_mcp_run.assert_called_once_with() - - @patch("oracle.oci_resource_search_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_only_host(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_HOST": "1.2.3.4", - } - mock_getenv.side_effect = lambda x: mock_env.get(x) - import oracle.oci_resource_search_mcp_server.server as server - - server.main() - mock_mcp_run.assert_called_once_with() - - @patch("oracle.oci_resource_search_mcp_server.server.mcp.run") - @patch("os.getenv") - def test_main_with_only_port(self, mock_getenv, mock_mcp_run): - mock_env = { - "ORACLE_MCP_PORT": "8888", - } - mock_getenv.side_effect = lambda x: mock_env.get(x) - import oracle.oci_resource_search_mcp_server.server as server - - server.main() - mock_mcp_run.assert_called_once_with() - - -class TestGetClient: - @patch( - "oracle.oci_resource_search_mcp_server.server.oci.resource_search.ResourceSearchClient" - ) - @patch( - "oracle.oci_resource_search_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_resource_search_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_resource_search_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_resource_search_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_resource_search_mcp_server.server.os.getenv") - def test_get_search_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default - ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - - # Act - result = server.get_search_client() - - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - expected_user_agent = f"{server.__project__.split('oracle.', 1)[1].split('-server', 1)[0]}/{server.__version__}" # noqa - assert passed_config.get("additional_user_agent") == expected_user_agent - # And we returned the client instance - assert result == mock_client.return_value - - @patch( - "oracle.oci_resource_search_mcp_server.server.oci.resource_search.ResourceSearchClient" - ) - @patch( - "oracle.oci_resource_search_mcp_server.server.oci.auth.signers.SecurityTokenSigner" - ) - @patch( - "oracle.oci_resource_search_mcp_server.server.oci.signer.load_private_key_from_file" - ) - @patch( - "oracle.oci_resource_search_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_resource_search_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_resource_search_mcp_server.server.os.getenv") - def test_get_search_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_search_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - assert "additional_user_agent" in config - assert ( - isinstance(config["additional_user_agent"], str) - and "/" in config["additional_user_agent"] - ) - # Returned object is client instance - assert srv_client is mock_client.return_value diff --git a/src/oci-resource-search-mcp-server/pyproject.toml b/src/oci-resource-search-mcp-server/pyproject.toml index 5b061699..d4395f10 100644 --- a/src/oci-resource-search-mcp-server/pyproject.toml +++ b/src/oci-resource-search-mcp-server/pyproject.toml @@ -49,3 +49,9 @@ omit = [ [tool.coverage.report] precision = 2 fail_under = 90 + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +] diff --git a/src/oci-usage-mcp-server/oracle/oci_usage_mcp_server/server.py b/src/oci-usage-mcp-server/oracle/oci_usage_mcp_server/server.py index de832340..d4ed70a2 100644 --- a/src/oci-usage-mcp-server/oracle/oci_usage_mcp_server/server.py +++ b/src/oci-usage-mcp-server/oracle/oci_usage_mcp_server/server.py @@ -10,34 +10,19 @@ import oci from fastmcp import FastMCP +from oci.usage_api import UsageapiClient from oci.usage_api.models import RequestSummarizedUsagesDetails +from oracle.mcp_common import with_oci_client -from . import __project__, __version__ +from . import __project__ logger = Logger(__name__, level="INFO") mcp = FastMCP(name=__project__) -def get_usage_client(): - logger.info("entering get_monitoring_client") - config = oci.config.from_file( - file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), - profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), - ) - user_agent_name = __project__.split("oracle.", 1)[1].split("-server", 1)[0] - config["additional_user_agent"] = f"{user_agent_name}/{__version__}" - - private_key = oci.signer.load_private_key_from_file(config["key_file"]) - token_file = os.path.expanduser(config["security_token_file"]) - token = None - with open(token_file, "r") as f: - token = f.read() - signer = oci.auth.signers.SecurityTokenSigner(token, private_key) - return oci.usage_api.UsageapiClient(config, signer=signer) - - @mcp.tool +@with_oci_client(UsageapiClient) def get_summarized_usage( tenant_id: Annotated[str, "Tenancy OCID"], start_time: Annotated[ @@ -72,8 +57,9 @@ def get_summarized_usage( "Specifies whether aggregated by time. If isAggregateByTime is true," "all usage or cost over the query time period will be added up.", ] = False, + *, + client: UsageapiClient, ) -> list[dict]: - usage_client = get_usage_client() summarized_details = RequestSummarizedUsagesDetails( tenant_id=tenant_id, time_usage_started=start_time, @@ -85,7 +71,7 @@ def get_summarized_usage( compartment_depth=compartment_depth, ) - response = usage_client.request_summarized_usages( + response = client.request_summarized_usages( request_summarized_usages_details=summarized_details ) # Convert UsageSummary objects to dictionaries for proper serialization diff --git a/src/oci-usage-mcp-server/oracle/oci_usage_mcp_server/tests/test_usage_tools.py b/src/oci-usage-mcp-server/oracle/oci_usage_mcp_server/tests/test_usage_tools.py index 50270cee..cf390090 100644 --- a/src/oci-usage-mcp-server/oracle/oci_usage_mcp_server/tests/test_usage_tools.py +++ b/src/oci-usage-mcp-server/oracle/oci_usage_mcp_server/tests/test_usage_tools.py @@ -4,7 +4,7 @@ https://oss.oracle.com/licenses/upl. """ -from unittest.mock import MagicMock, create_autospec, mock_open, patch +from unittest.mock import MagicMock, create_autospec, patch import oci import pytest @@ -15,10 +15,10 @@ class TestUsageTools: @pytest.mark.asyncio - @patch("oracle.oci_usage_mcp_server.server.get_usage_client") - async def test_get_summarized_usage(self, mock_get_client): + @patch("oracle.mcp_common.helpers._create_oci_client") + async def test_get_summarized_usage(self, mock_create_client): mock_client = MagicMock() - mock_get_client.return_value = mock_client + mock_create_client.return_value = mock_client mock_request_summarized_response = create_autospec(oci.response.Response) mock_request_summarized_response.data = oci.usage_api.models.QueryCollection( @@ -100,108 +100,3 @@ def test_main_with_only_port(self, mock_getenv, mock_mcp_run): mock_getenv.side_effect = lambda x: mock_env.get(x) server.main() mock_mcp_run.assert_called_once_with() - - -class TestGetClient: - @patch("oracle.oci_usage_mcp_server.server.oci.usage_api.UsageapiClient") - @patch("oracle.oci_usage_mcp_server.server.oci.auth.signers.SecurityTokenSigner") - @patch("oracle.oci_usage_mcp_server.server.oci.signer.load_private_key_from_file") - @patch( - "oracle.oci_usage_mcp_server.server.open", - new_callable=mock_open, - read_data="SECURITY_TOKEN", - ) - @patch("oracle.oci_usage_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_usage_mcp_server.server.os.getenv") - def test_get_search_client_with_profile_env( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: provide profile via env var and minimal config dict - mock_getenv.side_effect = lambda k, default=None: ( - "MYPROFILE" if k == "OCI_CONFIG_PROFILE" else default - ) - config = { - "key_file": "/abs/path/to/key.pem", - "security_token_file": "/abs/path/to/token", - } - mock_from_file.return_value = config - private_key_obj = object() - mock_load_private_key.return_value = private_key_obj - - # Act - result = server.get_usage_client() - - # Assert calls - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name="MYPROFILE", - ) - mock_open_file.assert_called_once_with("/abs/path/to/token", "r") - mock_security_token_signer.assert_called_once_with( - "SECURITY_TOKEN", private_key_obj - ) - # Ensure user agent was set on the same config dict passed into client - args, _ = mock_client.call_args - passed_config = args[0] - assert passed_config is config - expected_user_agent = f"{server.__project__.split('oracle.', 1)[1].split('-server', 1)[0]}/{server.__version__}" # noqa - assert passed_config.get("additional_user_agent") == expected_user_agent - # And we returned the client instance - assert result == mock_client.return_value - - @patch("oracle.oci_usage_mcp_server.server.oci.usage_api.UsageapiClient") - @patch("oracle.oci_usage_mcp_server.server.oci.auth.signers.SecurityTokenSigner") - @patch("oracle.oci_usage_mcp_server.server.oci.signer.load_private_key_from_file") - @patch( - "oracle.oci_usage_mcp_server.server.open", - new_callable=mock_open, - read_data="TOK", - ) - @patch("oracle.oci_usage_mcp_server.server.oci.config.from_file") - @patch("oracle.oci_usage_mcp_server.server.os.getenv") - def test_get_search_client_uses_default_profile_when_env_missing( - self, - mock_getenv, - mock_from_file, - mock_open_file, - mock_load_private_key, - mock_security_token_signer, - mock_client, - ): - # Arrange: no env var present; from_file should be called with DEFAULT_PROFILE - mock_getenv.side_effect = lambda k, default=None: default - config = {"key_file": "/k.pem", "security_token_file": "/tkn"} - mock_from_file.return_value = config - priv = object() - mock_load_private_key.return_value = priv - - # Act - srv_client = server.get_usage_client() - - # Assert: profile defaulted - mock_from_file.assert_called_once_with( - file_location=oci.config.DEFAULT_LOCATION, - profile_name=oci.config.DEFAULT_PROFILE, - ) - # Token file opened and read - mock_open_file.assert_called_once_with("/tkn", "r") - mock_security_token_signer.assert_called_once() - signer_args, _ = mock_security_token_signer.call_args - assert signer_args[0] == "TOK" - assert signer_args[1] is priv - # additional_user_agent set on original config and passed through - cc_args, _ = mock_client.call_args - assert cc_args[0] is config - assert "additional_user_agent" in config - assert ( - isinstance(config["additional_user_agent"], str) - and "/" in config["additional_user_agent"] - ) - # Returned object is client instance - assert srv_client is mock_client.return_value diff --git a/src/oci-usage-mcp-server/pyproject.toml b/src/oci-usage-mcp-server/pyproject.toml index 2a0c32fc..15af18fb 100644 --- a/src/oci-usage-mcp-server/pyproject.toml +++ b/src/oci-usage-mcp-server/pyproject.toml @@ -49,3 +49,9 @@ omit = [ [tool.coverage.report] precision = 2 fail_under = 90 + +[tool.pytest.ini_options] +pythonpath = [ + "..", + "../common", +]