Skip to content

Commit b076efe

Browse files
committed
feat: add get images (#20)
1 parent 65a7531 commit b076efe

File tree

4 files changed

+198
-59
lines changed

4 files changed

+198
-59
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from fastmcp import FastMCP
2+
3+
4+
def register_prompt(mcp: FastMCP):
5+
"""
6+
Register OpenStack MCP prompts.
7+
"""
8+
9+
# Add image tools prompt
10+
mcp.add_prompt(
11+
"image_tools",
12+
"ImageTools Usage Guide - Only use parameters that the user explicitly requests",
13+
"prompts/image_tools_prompt.md",
14+
)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# ImageTools Usage Guide
2+
3+
## get_images Method
4+
5+
### Core Principles
6+
- **Only use parameters that the user explicitly requests**
7+
- **Do NOT add default values or assume values for parameters not mentioned**
8+
- **Leave parameters as null if the user doesn't specify them**
9+
10+
### Usage Examples
11+
12+
#### 1. "Show me public images"
13+
```json
14+
{
15+
"visibility": "public"
16+
}
17+
```
18+
- `status` and `name` are not set (null)
19+
20+
#### 2. "Show me active status images"
21+
```json
22+
{
23+
"status": "active"
24+
}
25+
```
26+
- `visibility` and `name` are not set (null)
27+
28+
#### 3. "Show me ubuntu images"
29+
```json
30+
{
31+
"name": "ubuntu"
32+
}
33+
```
34+
- `status` and `visibility` are not set (null)
35+
36+
#### 4. "Show me public active images"
37+
```json
38+
{
39+
"visibility": "public",
40+
"status": "active"
41+
}
42+
```
43+
- `name` is not set (null)
44+
45+
### Important Notes
46+
- If user asks for "public images only", do NOT automatically set `status` to "active"
47+
- If user asks for "list of images", leave all parameters as null
48+
- When in doubt, ask the user for clarification rather than making assumptions
49+
- Always respect the user's explicit requests and avoid adding unrequested filters

src/openstack_mcp_server/tools/image_tools.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,26 +16,45 @@ def register_tools(self, mcp: FastMCP):
1616
Register Image-related tools with the FastMCP instance.
1717
"""
1818

19-
mcp.tool()(self.get_image_images)
19+
mcp.tool()(self.get_images)
2020
mcp.tool()(self.create_image)
2121

22-
def get_image_images(self) -> str:
22+
def get_images(
23+
self,
24+
name: str | None = None,
25+
status: str | None = None,
26+
visibility: str | None = None,
27+
) -> list[Image]:
2328
"""
24-
Get the list of Image images by invoking the registered tool.
29+
Get the list of OpenStack images with optional filtering.
2530
26-
:return: A string containing the names, IDs, and statuses of the images.
31+
Prompt:
32+
See prompts/image_tools_prompt.md for detailed usage examples.
33+
34+
Args:
35+
name: Filter by image name
36+
status: Filter by status (e.g., 'active', 'queued', 'killed')
37+
visibility: Filter by visibility (e.g., 'public', 'private', 'shared')
38+
39+
Returns:
40+
A list of Image objects.
2741
"""
28-
# Initialize connection
2942
conn = get_openstack_conn()
3043

31-
# List the servers
44+
# Build filters for the image query
45+
filters = {}
46+
if name and name.strip():
47+
filters["name"] = name.strip()
48+
if status and status.strip():
49+
filters["status"] = status.strip()
50+
if visibility and visibility.strip():
51+
filters["visibility"] = visibility.strip()
52+
3253
image_list = []
33-
for image in conn.image.images():
34-
image_list.append(
35-
f"{image.name} ({image.id}) - Status: {image.status}",
36-
)
54+
for image in conn.image.images(**filters):
55+
image_list.append(Image(**image))
3756

38-
return "\n".join(image_list)
57+
return image_list
3958

4059
def create_image(self, image_data: CreateImage) -> Image:
4160
"""Create a new Openstack image.

tests/tools/test_image_tools.py

Lines changed: 105 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from unittest.mock import Mock
44

5+
import pytest
6+
57
from openstack_mcp_server.tools.image_tools import ImageTools
68
from openstack_mcp_server.tools.request.image import CreateImage
79
from openstack_mcp_server.tools.response.image import Image
@@ -46,80 +48,135 @@ def image_factory(**overrides):
4648

4749
return defaults
4850

49-
def test_get_image_images_success(self, mock_get_openstack_conn_image):
51+
def test_get_images_success(self, mock_get_openstack_conn_image):
5052
"""Test getting image images successfully."""
5153
mock_conn = mock_get_openstack_conn_image
5254

53-
# Create mock image objects
54-
mock_image1 = Mock()
55-
mock_image1.name = "ubuntu-20.04-server"
56-
mock_image1.id = "img-123-abc-def"
57-
mock_image1.status = "active"
55+
mock_image1 = self.image_factory(
56+
id="img-123-abc-def",
57+
name="ubuntu-20.04-server",
58+
status="active",
59+
visibility="public",
60+
checksum="abc123",
61+
size=1073741824,
62+
)
5863

59-
mock_image2 = Mock()
60-
mock_image2.name = "centos-8-stream"
61-
mock_image2.id = "img-456-ghi-jkl"
62-
mock_image2.status = "active"
64+
mock_image2 = self.image_factory(
65+
id="img-456-ghi-jkl",
66+
name="centos-8-stream",
67+
status="active",
68+
visibility="public",
69+
checksum="def456",
70+
size=2147483648,
71+
)
6372

64-
# Configure mock image.images()
6573
mock_conn.image.images.return_value = [mock_image1, mock_image2]
6674

67-
# Test ImageTools
68-
image_tools = ImageTools()
69-
result = image_tools.get_image_images()
75+
result = ImageTools().get_images()
7076

71-
# Verify results
72-
expected_output = (
73-
"ubuntu-20.04-server (img-123-abc-def) - Status: active\n"
74-
"centos-8-stream (img-456-ghi-jkl) - Status: active"
75-
)
77+
expected_output = [
78+
Image(**mock_image1),
79+
Image(**mock_image2),
80+
]
7681
assert result == expected_output
7782

78-
# Verify mock calls
7983
mock_conn.image.images.assert_called_once()
8084

81-
def test_get_image_images_empty_list(self, mock_get_openstack_conn_image):
85+
def test_get_images_empty_list(self, mock_get_openstack_conn_image):
8286
"""Test getting image images when no images exist."""
8387
mock_conn = mock_get_openstack_conn_image
84-
85-
# Empty image list
8688
mock_conn.image.images.return_value = []
8789

88-
image_tools = ImageTools()
89-
result = image_tools.get_image_images()
90-
91-
# Verify empty string
92-
assert result == ""
90+
result = ImageTools().get_images()
9391

92+
assert result == []
9493
mock_conn.image.images.assert_called_once()
9594

96-
def test_get_image_images_with_empty_name(
95+
@pytest.mark.parametrize(
96+
"filter_name,filter_value,expected_count",
97+
[
98+
("name", "ubuntu-20.04-server", 1), # exact name match
99+
("name", "ubuntu", 0), # partial match not supported
100+
("name", "nonexistent", 0), # non-existent name
101+
("name", "", 2), # empty filter value
102+
("name", " ", 2), # whitespace only
103+
("status", "active", 2),
104+
("visibility", "public", 2),
105+
("status", "deleted", 0),
106+
("visibility", "private", 0),
107+
],
108+
)
109+
def test_get_images_with_filters(
97110
self,
98111
mock_get_openstack_conn_image,
112+
filter_name,
113+
filter_value,
114+
expected_count,
99115
):
100-
"""Test images with empty or None names."""
116+
"""Test getting images with various filters."""
101117
mock_conn = mock_get_openstack_conn_image
102118

103-
# Images with empty name (edge case)
104-
mock_image1 = Mock()
105-
mock_image1.name = "normal-image"
106-
mock_image1.id = "img-normal"
107-
mock_image1.status = "active"
108-
109-
mock_image2 = Mock()
110-
mock_image2.name = "" # Empty name
111-
mock_image2.id = "img-empty-name"
112-
mock_image2.status = "active"
113-
114-
mock_conn.image.images.return_value = [mock_image1, mock_image2]
115-
116-
image_tools = ImageTools()
117-
result = image_tools.get_image_images()
119+
mock_image1 = self.image_factory(
120+
id="img-123-abc-def",
121+
name="ubuntu-20.04-server",
122+
status="active",
123+
visibility="public",
124+
checksum="abc123",
125+
size=1073741824,
126+
)
118127

119-
assert "normal-image (img-normal) - Status: active" in result
120-
assert " (img-empty-name) - Status: active" in result # Empty name
128+
mock_image2 = self.image_factory(
129+
id="img-456-ghi-jkl",
130+
name="centos-8-stream",
131+
status="active",
132+
visibility="public",
133+
checksum="def456",
134+
size=2147483648,
135+
)
121136

122-
mock_conn.image.images.assert_called_once()
137+
if filter_name == "name":
138+
if filter_value == "ubuntu-20.04-server":
139+
mock_conn.image.images.return_value = [mock_image1]
140+
elif filter_value in ["", " "]:
141+
mock_conn.image.images.return_value = [
142+
mock_image1,
143+
mock_image2,
144+
]
145+
else:
146+
mock_conn.image.images.return_value = []
147+
elif filter_name == "status":
148+
if filter_value == "active":
149+
mock_conn.image.images.return_value = [
150+
mock_image1,
151+
mock_image2,
152+
]
153+
else:
154+
mock_conn.image.images.return_value = []
155+
elif filter_name == "visibility":
156+
if filter_value == "public":
157+
mock_conn.image.images.return_value = [
158+
mock_image1,
159+
mock_image2,
160+
]
161+
else:
162+
mock_conn.image.images.return_value = []
163+
164+
result = ImageTools().get_images(**{filter_name: filter_value})
165+
166+
if expected_count == 0:
167+
assert result == []
168+
elif expected_count == 1:
169+
assert result == [Image(**mock_image1)]
170+
else:
171+
assert result == [Image(**mock_image1), Image(**mock_image2)]
172+
173+
# For empty/whitespace filters, no filter should be applied
174+
if filter_value in ["", " "]:
175+
mock_conn.image.images.assert_called_once_with()
176+
else:
177+
mock_conn.image.images.assert_called_once_with(
178+
**{filter_name: filter_value}
179+
)
123180

124181
def test_create_image_success_with_volume_id(
125182
self,

0 commit comments

Comments
 (0)