Skip to content

Commit d18cd10

Browse files
authored
Refactor: connection reuse (#12)
1 parent b44c10c commit d18cd10

File tree

8 files changed

+319
-108
lines changed

8 files changed

+319
-108
lines changed

src/openstack_mcp_server/config.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33

44

55
# Transport protocol
6-
MCP_TRANSPORT = os.environ.get("TRANSPORT", "stdio")
6+
MCP_TRANSPORT: str = os.environ.get("TRANSPORT", "stdio")
7+
8+
# Openstack client settings
9+
MCP_CLOUD_NAME: str = os.environ.get("CLOUD_NAME", "openstack")
10+
MCP_DEBUG_MODE: bool = os.environ.get("DEBUG_MODE", "true").lower() == "true"
711

812
# Application paths
913
BASE_DIR = Path(__file__).parent.parent.parent

src/openstack_mcp_server/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ def register_tool(mcp: FastMCP):
55
"""
66
Register Openstack MCP tools.
77
"""
8+
from .glance_tools import GlanceTools
89
from .nova_tools import NovaTools
910

1011
NovaTools().register_tools(mcp)
12+
GlanceTools.register_tools(mcp)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import openstack
2+
from openstack import connection
3+
from openstack_mcp_server import config
4+
5+
6+
class OpenStackConnectionManager:
7+
"""OpenStack Connection Manager"""
8+
9+
_connection: connection.Connection | None = None
10+
11+
# TODO: Try/Catch disconnection by token expired case
12+
@classmethod
13+
def get_connection(cls) -> connection.Connection:
14+
"""OpenStack Connection)"""
15+
if cls._connection is None:
16+
openstack.enable_logging(debug=config.MCP_DEBUG_MODE)
17+
cls._connection = openstack.connect(cloud=config.MCP_CLOUD_NAME)
18+
return cls._connection
19+
20+
21+
# TODO: Close connection
22+
23+
24+
def get_openstack_conn():
25+
"""Get OpenStack Connection"""
26+
return OpenStackConnectionManager.get_connection()
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from .base import get_openstack_conn
2+
from mcp.server.fastmcp import FastMCP
3+
4+
5+
class GlanceTools:
6+
"""
7+
A class to encapsulate Nova-related tools and utilities.
8+
"""
9+
10+
def register_tools(self, mcp: FastMCP):
11+
"""
12+
Register Glance-related tools with the FastMCP instance.
13+
"""
14+
15+
mcp.tool()(self.get_glance_images)
16+
17+
def get_glance_images(self) -> str:
18+
"""
19+
Get the list of Glance images by invoking the registered tool.
20+
21+
:return: A string containing the names, IDs, and statuses of the images.
22+
"""
23+
# Initialize connection
24+
conn = get_openstack_conn()
25+
26+
# List the servers
27+
image_list = []
28+
for image in conn.image.images():
29+
image_list.append(
30+
f"{image.name} ({image.id}) - Status: {image.status}"
31+
)
32+
33+
return "\n".join(image_list)

src/openstack_mcp_server/tools/nova_tools.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import openstack
1+
from .base import get_openstack_conn
22
from mcp.server.fastmcp import FastMCP
33

44

@@ -7,11 +7,6 @@ class NovaTools:
77
A class to encapsulate Nova-related tools and utilities.
88
"""
99

10-
def __init__(self):
11-
"""
12-
Initialize the NovaTools with a FastMCP instance.
13-
"""
14-
1510
def register_tools(self, mcp: FastMCP):
1611
"""
1712
Register Nova-related tools with the FastMCP instance.
@@ -25,16 +20,12 @@ def get_nova_servers(self) -> str:
2520
2621
:return: A string containing the names, IDs, and statuses of the servers.
2722
"""
28-
29-
# Initialize and turn on debug logging
30-
openstack.enable_logging(debug=True)
31-
3223
# Initialize connection
33-
conn = openstack.connect(cloud="openstack")
24+
conn = get_openstack_conn()
3425

3526
# List the servers
3627
server_list = []
37-
for server in conn.list_servers():
28+
for server in conn.compute.list_servers():
3829
server_list.append(
3930
f"{server.name} ({server.id}) - Status: {server.status}"
4031
)

tests/conftest.py

Lines changed: 22 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,60 +3,36 @@
33

44

55
@pytest.fixture
6-
def mock_openstack_logging():
7-
"""Mock openstack.enable_logging function."""
8-
with patch(
9-
"openstack_mcp_server.tools.nova_tools.openstack.enable_logging"
10-
) as mock:
11-
yield mock
12-
6+
def mock_get_openstack_conn():
7+
"""Mock get_openstack_conn function for nova_tools."""
8+
mock_conn = Mock()
139

14-
@pytest.fixture
15-
def mock_openstack_connect():
16-
"""Mock openstack.connect function."""
1710
with patch(
18-
"openstack_mcp_server.tools.nova_tools.openstack.connect"
19-
) as mock:
20-
yield mock
11+
"openstack_mcp_server.tools.nova_tools.get_openstack_conn",
12+
return_value=mock_conn
13+
) as mock_func:
14+
yield mock_conn
2115

2216

2317
@pytest.fixture
24-
def mock_openstack_connection():
25-
"""Mock openstack connection object."""
18+
def mock_get_openstack_conn_glance():
19+
"""Mock get_openstack_conn function for glance_tools."""
2620
mock_conn = Mock()
27-
with patch(
28-
"openstack_mcp_server.tools.nova_tools.openstack.connect",
29-
return_value=mock_conn,
30-
) as mock_connect:
31-
yield mock_conn, mock_connect
32-
33-
34-
@pytest.fixture
35-
def mock_server():
36-
"""Create a mock server object."""
37-
38-
def _create_server(
39-
name="test-server", server_id="test-id", status="ACTIVE"
40-
):
41-
mock = Mock()
42-
mock.name = name
43-
mock.id = server_id
44-
mock.status = status
45-
return mock
4621

47-
return _create_server
22+
with patch(
23+
"openstack_mcp_server.tools.glance_tools.get_openstack_conn",
24+
return_value=mock_conn
25+
) as mock_func:
26+
yield mock_conn
4827

4928

5029
@pytest.fixture
51-
def sample_servers(mock_server):
52-
"""Create sample server list."""
53-
return [
54-
mock_server("server1", "id1", "ACTIVE"),
55-
mock_server("server2", "id2", "STOPPED"),
56-
]
57-
30+
def mock_openstack_base():
31+
"""Mock base module functions."""
32+
mock_conn = Mock()
5833

59-
@pytest.fixture
60-
def mock_fastmcp():
61-
"""Mock FastMCP instance."""
62-
return Mock()
34+
with patch(
35+
"openstack_mcp_server.tools.base.get_openstack_conn",
36+
return_value=mock_conn
37+
) as mock_func:
38+
yield mock_conn

tests/tools/test_glance_tools.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import pytest
2+
from unittest.mock import Mock
3+
from openstack_mcp_server.tools.glance_tools import GlanceTools
4+
5+
6+
class TestGlanceTools:
7+
"""Test cases for GlanceTools class."""
8+
9+
def test_get_glance_images_success(self, mock_get_openstack_conn_glance):
10+
"""Test getting glance images successfully."""
11+
mock_conn = mock_get_openstack_conn_glance
12+
13+
# Create mock image objects
14+
mock_image1 = Mock()
15+
mock_image1.name = "ubuntu-20.04-server"
16+
mock_image1.id = "img-123-abc-def"
17+
mock_image1.status = "active"
18+
19+
mock_image2 = Mock()
20+
mock_image2.name = "centos-8-stream"
21+
mock_image2.id = "img-456-ghi-jkl"
22+
mock_image2.status = "active"
23+
24+
# Configure mock image.images()
25+
mock_conn.image.images.return_value = [mock_image1, mock_image2]
26+
27+
# Test GlanceTools
28+
glance_tools = GlanceTools()
29+
result = glance_tools.get_glance_images()
30+
31+
# Verify results
32+
expected_output = (
33+
"ubuntu-20.04-server (img-123-abc-def) - Status: active\n"
34+
"centos-8-stream (img-456-ghi-jkl) - Status: active"
35+
)
36+
assert result == expected_output
37+
38+
# Verify mock calls
39+
mock_conn.image.images.assert_called_once()
40+
41+
def test_get_glance_images_empty_list(self, mock_get_openstack_conn_glance):
42+
"""Test getting glance images when no images exist."""
43+
mock_conn = mock_get_openstack_conn_glance
44+
45+
# Empty image list
46+
mock_conn.image.images.return_value = []
47+
48+
glance_tools = GlanceTools()
49+
result = glance_tools.get_glance_images()
50+
51+
# Verify empty string
52+
assert result == ""
53+
54+
mock_conn.image.images.assert_called_once()
55+
56+
def test_get_glance_images_with_empty_name(self, mock_get_openstack_conn_glance):
57+
"""Test images with empty or None names."""
58+
mock_conn = mock_get_openstack_conn_glance
59+
60+
# Images with empty name (edge case)
61+
mock_image1 = Mock()
62+
mock_image1.name = "normal-image"
63+
mock_image1.id = "img-normal"
64+
mock_image1.status = "active"
65+
66+
mock_image2 = Mock()
67+
mock_image2.name = "" # Empty name
68+
mock_image2.id = "img-empty-name"
69+
mock_image2.status = "active"
70+
71+
mock_conn.image.images.return_value = [mock_image1, mock_image2]
72+
73+
glance_tools = GlanceTools()
74+
result = glance_tools.get_glance_images()
75+
76+
assert "normal-image (img-normal) - Status: active" in result
77+
assert " (img-empty-name) - Status: active" in result # Empty name
78+
79+
mock_conn.image.images.assert_called_once()

0 commit comments

Comments
 (0)