Skip to content

Commit db21adc

Browse files
committed
feat(network): improve feedback (#87)
1 parent a666530 commit db21adc

File tree

2 files changed

+141
-124
lines changed

2 files changed

+141
-124
lines changed

src/openstack_mcp_server/tools/network_tools.py

Lines changed: 77 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def register_tools(self, mcp: FastMCP):
4444
mcp.tool()(self.delete_port)
4545
mcp.tool()(self.get_port_allowed_address_pairs)
4646
mcp.tool()(self.set_port_binding)
47-
mcp.tool()(self.add_security_group_to_port)
47+
mcp.tool()(self.add_port_to_security_group)
4848
mcp.tool()(self.remove_security_group_from_port)
4949
mcp.tool()(self.get_floating_ips)
5050
mcp.tool()(self.create_floating_ip)
@@ -517,7 +517,7 @@ def set_port_binding(
517517
updated = conn.network.update_port(port_id, **update_args)
518518
return self._convert_to_port_model(updated)
519519

520-
def add_security_group_to_port(
520+
def add_port_to_security_group(
521521
self,
522522
port_id: str,
523523
security_group_id: str,
@@ -534,7 +534,7 @@ def add_security_group_to_port(
534534
"""
535535
conn = get_openstack_conn()
536536
current = conn.network.get_port(port_id)
537-
current_group_ids: list[str] = list(current.security_group_ids or [])
537+
current_group_ids: list[str] = list(current.security_group_ids)
538538
if security_group_id in current_group_ids:
539539
return self._convert_to_port_model(current)
540540

@@ -561,7 +561,7 @@ def remove_security_group_from_port(
561561
"""
562562
conn = get_openstack_conn()
563563
current = conn.network.get_port(port_id)
564-
current_group_ids: list[str] = list(current.security_group_ids or [])
564+
current_group_ids: list[str] = list(current.security_group_ids)
565565
if security_group_id not in current_group_ids:
566566
return self._convert_to_port_model(current)
567567

@@ -1233,6 +1233,25 @@ def _sanitize_server_filters(self, filters: dict) -> dict:
12331233
attrs.pop("status", None)
12341234
return attrs
12351235

1236+
def _coerce_port(self, value) -> int | None:
1237+
"""
1238+
Coerce a port value that may arrive as a string (e.g., "80") into int.
1239+
1240+
This relaxes tool input validation issues from UIs that serialize numbers
1241+
as strings. Only base-10 digit strings are converted; otherwise returns
1242+
None so the field can be omitted.
1243+
1244+
:param value: Port as int, str, or None
1245+
:return: int port or None
1246+
"""
1247+
if isinstance(value, int):
1248+
return value
1249+
if isinstance(value, str):
1250+
stripped = value.strip()
1251+
if stripped.isdigit():
1252+
return int(stripped)
1253+
return None
1254+
12361255
def get_security_groups(
12371256
self,
12381257
project_id: str | None = None,
@@ -1344,19 +1363,12 @@ def _convert_to_security_group_model(self, openstack_sg) -> SecurityGroup:
13441363
:param openstack_sg: OpenStack security group object
13451364
:return: Pydantic SecurityGroup model
13461365
"""
1347-
rule_ids: list[str] | None = None
13481366
rules = getattr(openstack_sg, "security_group_rules", None)
1349-
if rules is not None:
1350-
extracted: list[str] = []
1351-
for r in rules:
1352-
rid = (
1353-
r.get("id")
1354-
if isinstance(r, dict)
1355-
else getattr(r, "id", None)
1356-
)
1357-
if rid:
1358-
extracted.append(str(rid))
1359-
rule_ids = extracted
1367+
rule_ids: list[str] | None = (
1368+
[r.get("id") for r in rules if r.get("id")]
1369+
if rules is not None
1370+
else None
1371+
)
13601372

13611373
return SecurityGroup(
13621374
id=openstack_sg.id,
@@ -1373,8 +1385,8 @@ def create_security_group_rule(
13731385
direction: str = "ingress",
13741386
ethertype: str = "IPv4",
13751387
protocol: str | None = None,
1376-
port_range_min: int | None = None,
1377-
port_range_max: int | None = None,
1388+
port_range_min: int | str | None = None,
1389+
port_range_max: int | str | None = None,
13781390
remote_ip_prefix: str | None = None,
13791391
remote_group_id: str | None = None,
13801392
description: str | None = None,
@@ -1396,20 +1408,31 @@ def create_security_group_rule(
13961408
:return: Created SecurityGroupRule
13971409
"""
13981410
conn = get_openstack_conn()
1399-
args: dict = {
1400-
"security_group_id": security_group_id,
1401-
"direction": direction,
1402-
"ethertype": ethertype,
1403-
}
1404-
args["protocol"] = protocol
1405-
args["port_range_min"] = port_range_min
1406-
args["port_range_max"] = port_range_max
1407-
args["remote_ip_prefix"] = remote_ip_prefix
1408-
args["remote_group_id"] = remote_group_id
1409-
args["description"] = description
1410-
args["project_id"] = project_id
1411-
rule = conn.network.create_security_group_rule(**args)
1412-
return self._convert_to_security_group_rule_model(rule)
1411+
create_args: dict = {}
1412+
if protocol is not None:
1413+
create_args["protocol"] = protocol
1414+
coerced_min = self._coerce_port(port_range_min)
1415+
coerced_max = self._coerce_port(port_range_max)
1416+
if coerced_min is not None:
1417+
create_args["port_range_min"] = coerced_min
1418+
if coerced_max is not None:
1419+
create_args["port_range_max"] = coerced_max
1420+
if remote_ip_prefix is not None:
1421+
create_args["remote_ip_prefix"] = remote_ip_prefix
1422+
if remote_group_id is not None:
1423+
create_args["remote_group_id"] = remote_group_id
1424+
if description is not None:
1425+
create_args["description"] = description
1426+
if project_id is not None:
1427+
create_args["project_id"] = project_id
1428+
1429+
rule = conn.network.create_security_group_rule(
1430+
security_group_id=security_group_id,
1431+
direction=direction,
1432+
ethertype=ethertype,
1433+
**create_args,
1434+
)
1435+
return SecurityGroupRule(**rule)
14131436

14141437
def get_security_group_rule_detail(
14151438
self, rule_id: str
@@ -1422,7 +1445,7 @@ def get_security_group_rule_detail(
14221445
"""
14231446
conn = get_openstack_conn()
14241447
rule = conn.network.get_security_group_rule(rule_id)
1425-
return self._convert_to_security_group_rule_model(rule)
1448+
return SecurityGroupRule(**rule)
14261449

14271450
def delete_security_group_rule(self, rule_id: str) -> None:
14281451
"""
@@ -1450,32 +1473,25 @@ def create_security_group_rules_bulk(
14501473
:return: List of created SecurityGroupRule models
14511474
"""
14521475
conn = get_openstack_conn()
1453-
created = conn.network.create_security_group_rules(rules=rules)
1454-
return [self._convert_to_security_group_rule_model(r) for r in created]
14551476

1456-
def _convert_to_security_group_rule_model(
1457-
self, openstack_rule
1458-
) -> SecurityGroupRule:
1459-
"""
1460-
Convert an OpenStack Security Group Rule object to a pydantic model.
1461-
1462-
:param openstack_rule: OpenStack rule object
1463-
:return: SecurityGroupRule model
1464-
"""
1465-
return SecurityGroupRule(
1466-
id=getattr(openstack_rule, "id"),
1467-
name=getattr(openstack_rule, "name", None),
1468-
status=getattr(openstack_rule, "status", None),
1469-
description=getattr(openstack_rule, "description", None),
1470-
project_id=getattr(openstack_rule, "project_id", None),
1471-
direction=getattr(openstack_rule, "direction", None),
1472-
ethertype=getattr(openstack_rule, "ethertype", None),
1473-
protocol=getattr(openstack_rule, "protocol", None),
1474-
port_range_min=getattr(openstack_rule, "port_range_min", None),
1475-
port_range_max=getattr(openstack_rule, "port_range_max", None),
1476-
remote_ip_prefix=getattr(openstack_rule, "remote_ip_prefix", None),
1477-
remote_group_id=getattr(openstack_rule, "remote_group_id", None),
1478-
security_group_id=getattr(
1479-
openstack_rule, "security_group_id", None
1480-
),
1481-
)
1477+
def _clean_rule_payload(raw: dict) -> dict:
1478+
payload = dict(raw)
1479+
# Remove port ranges when protocol is absent (Neutron requirement)
1480+
if not payload.get("protocol"):
1481+
payload.pop("port_range_min", None)
1482+
payload.pop("port_range_max", None)
1483+
else:
1484+
# Coerce possible string ports to integers
1485+
for key in ("port_range_min", "port_range_max"):
1486+
if key in payload:
1487+
coerced = self._coerce_port(payload.get(key))
1488+
if coerced is None:
1489+
payload.pop(key, None)
1490+
else:
1491+
payload[key] = coerced
1492+
# Drop keys explicitly set to None
1493+
return {k: v for k, v in payload.items() if v is not None}
1494+
1495+
cleaned_rules = [_clean_rule_payload(r) for r in rules]
1496+
created = conn.network.create_security_group_rules(rules=cleaned_rules)
1497+
return [SecurityGroupRule(**r) for r in created]

tests/tools/test_network_tools.py

Lines changed: 64 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -909,15 +909,15 @@ def test_add_remove_security_group_on_port(
909909
mock_conn.network.update_port.return_value = updated_add
910910

911911
tools = self.get_network_tools()
912-
res_add = tools.add_security_group_to_port("port-1", "sg-9")
912+
res_add = tools.add_port_to_security_group("port-1", "sg-9")
913913
assert isinstance(res_add, Port)
914914
mock_conn.network.update_port.assert_called_with(
915915
"port-1", security_groups=["sg-1", "sg-2", "sg-9"]
916916
)
917917

918918
# idempotent add when sg already present
919919
mock_conn.network.get_port.return_value = updated_add
920-
res_add_again = tools.add_security_group_to_port("port-1", "sg-9")
920+
res_add_again = tools.add_port_to_security_group("port-1", "sg-9")
921921
assert isinstance(res_add_again, Port)
922922

923923
# updated after remove
@@ -1460,10 +1460,7 @@ def test_get_security_groups_filters(self, mock_openstack_connect_network):
14601460
sg.status = None
14611461
sg.description = "desc"
14621462
sg.project_id = "proj-1"
1463-
sg.security_group_rules = [
1464-
{"id": "r-1"},
1465-
{"id": "r-2"},
1466-
]
1463+
sg.security_group_rules = [{"id": "r-1"}, {"id": "r-2"}]
14671464

14681465
expected_sg = SecurityGroup(
14691466
id="sg-1",
@@ -1852,20 +1849,21 @@ def test_add_get_remove_router_interface_by_port(
18521849

18531850
def test_create_security_group_rule(self, mock_openstack_connect_network):
18541851
mock_conn = mock_openstack_connect_network
1855-
rule = Mock()
1856-
rule.id = "r-1"
1857-
rule.name = None
1858-
rule.status = None
1859-
rule.description = "allow 22"
1860-
rule.project_id = "proj-1"
1861-
rule.direction = "ingress"
1862-
rule.ethertype = "IPv4"
1863-
rule.protocol = "tcp"
1864-
rule.port_range_min = 22
1865-
rule.port_range_max = 22
1866-
rule.remote_ip_prefix = "0.0.0.0/0"
1867-
rule.remote_group_id = None
1868-
rule.security_group_id = "sg-1"
1852+
rule = {
1853+
"id": "r-1",
1854+
"name": None,
1855+
"status": None,
1856+
"description": "allow 22",
1857+
"project_id": "proj-1",
1858+
"direction": "ingress",
1859+
"ethertype": "IPv4",
1860+
"protocol": "tcp",
1861+
"port_range_min": 22,
1862+
"port_range_max": 22,
1863+
"remote_ip_prefix": "0.0.0.0/0",
1864+
"remote_group_id": None,
1865+
"security_group_id": "sg-1",
1866+
}
18691867
mock_conn.network.create_security_group_rule.return_value = rule
18701868

18711869
tools = self.get_network_tools()
@@ -1887,20 +1885,21 @@ def test_get_security_group_rule_detail(
18871885
self, mock_openstack_connect_network
18881886
):
18891887
mock_conn = mock_openstack_connect_network
1890-
rule = Mock()
1891-
rule.id = "r-2"
1892-
rule.name = None
1893-
rule.status = None
1894-
rule.description = None
1895-
rule.project_id = None
1896-
rule.direction = "egress"
1897-
rule.ethertype = "IPv4"
1898-
rule.protocol = None
1899-
rule.port_range_min = None
1900-
rule.port_range_max = None
1901-
rule.remote_ip_prefix = None
1902-
rule.remote_group_id = None
1903-
rule.security_group_id = "sg-1"
1888+
rule = {
1889+
"id": "r-2",
1890+
"name": None,
1891+
"status": None,
1892+
"description": None,
1893+
"project_id": None,
1894+
"direction": "egress",
1895+
"ethertype": "IPv4",
1896+
"protocol": None,
1897+
"port_range_min": None,
1898+
"port_range_max": None,
1899+
"remote_ip_prefix": None,
1900+
"remote_group_id": None,
1901+
"security_group_id": "sg-1",
1902+
}
19041903
mock_conn.network.get_security_group_rule.return_value = rule
19051904

19061905
tools = self.get_network_tools()
@@ -1925,35 +1924,37 @@ def test_create_security_group_rules_bulk(
19251924
self, mock_openstack_connect_network
19261925
):
19271926
mock_conn = mock_openstack_connect_network
1928-
r1 = Mock()
1929-
r1.id = "r-10"
1930-
r1.name = None
1931-
r1.status = None
1932-
r1.description = None
1933-
r1.project_id = None
1934-
r1.security_group_id = "sg-1"
1935-
r1.direction = "ingress"
1936-
r1.ethertype = "IPv4"
1937-
r1.protocol = "tcp"
1938-
r1.port_range_min = 80
1939-
r1.port_range_max = 80
1940-
r1.remote_ip_prefix = "0.0.0.0/0"
1941-
r1.remote_group_id = None
1942-
1943-
r2 = Mock()
1944-
r2.id = "r-11"
1945-
r2.name = None
1946-
r2.status = None
1947-
r2.description = None
1948-
r2.project_id = None
1949-
r2.security_group_id = "sg-1"
1950-
r2.direction = "ingress"
1951-
r2.ethertype = "IPv4"
1952-
r2.protocol = "tcp"
1953-
r2.port_range_min = 443
1954-
r2.port_range_max = 443
1955-
r2.remote_ip_prefix = "0.0.0.0/0"
1956-
r2.remote_group_id = None
1927+
r1 = {
1928+
"id": "r-10",
1929+
"name": None,
1930+
"status": None,
1931+
"description": None,
1932+
"project_id": None,
1933+
"security_group_id": "sg-1",
1934+
"direction": "ingress",
1935+
"ethertype": "IPv4",
1936+
"protocol": "tcp",
1937+
"port_range_min": 80,
1938+
"port_range_max": 80,
1939+
"remote_ip_prefix": "0.0.0.0/0",
1940+
"remote_group_id": None,
1941+
}
1942+
1943+
r2 = {
1944+
"id": "r-11",
1945+
"name": None,
1946+
"status": None,
1947+
"description": None,
1948+
"project_id": None,
1949+
"security_group_id": "sg-1",
1950+
"direction": "ingress",
1951+
"ethertype": "IPv4",
1952+
"protocol": "tcp",
1953+
"port_range_min": 443,
1954+
"port_range_max": 443,
1955+
"remote_ip_prefix": "0.0.0.0/0",
1956+
"remote_group_id": None,
1957+
}
19571958

19581959
mock_conn.network.create_security_group_rules.return_value = [r1, r2]
19591960

0 commit comments

Comments
 (0)