@@ -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 ]
0 commit comments