Skip to content

Commit c5ce45d

Browse files
committed
feat(network): add security group binding to port to mcp tools (#87)
1 parent 6b49119 commit c5ce45d

File tree

2 files changed

+139
-0
lines changed

2 files changed

+139
-0
lines changed

src/openstack_mcp_server/tools/network_tools.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ 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)
48+
mcp.tool()(self.remove_security_group_from_port)
4749
mcp.tool()(self.get_floating_ips)
4850
mcp.tool()(self.create_floating_ip)
4951
mcp.tool()(self.delete_floating_ip)
@@ -515,6 +517,62 @@ def set_port_binding(
515517
updated = conn.network.update_port(port_id, **update_args)
516518
return self._convert_to_port_model(updated)
517519

520+
def add_security_group_to_port(
521+
self,
522+
port_id: str,
523+
security_group_id: str,
524+
) -> Port:
525+
"""
526+
Attach a Security Group to a Port.
527+
528+
Idempotent: if the security group is already attached, returns the current
529+
port state without issuing an update.
530+
531+
:param port_id: Port ID
532+
:param security_group_id: Security Group ID to attach
533+
:return: Updated (or current) Port object
534+
"""
535+
conn = get_openstack_conn()
536+
current = conn.network.get_port(port_id)
537+
current_group_ids: list[str] = list(current.security_group_ids or [])
538+
if security_group_id in current_group_ids:
539+
return self._convert_to_port_model(current)
540+
541+
updated_group_ids = current_group_ids + [security_group_id]
542+
updated = conn.network.update_port(
543+
port_id, security_groups=updated_group_ids
544+
)
545+
return self._convert_to_port_model(updated)
546+
547+
def remove_security_group_from_port(
548+
self,
549+
port_id: str,
550+
security_group_id: str,
551+
) -> Port:
552+
"""
553+
Detach a Security Group from a Port.
554+
555+
Idempotent: if the security group is not attached, returns the current
556+
port state without issuing an update.
557+
558+
:param port_id: Port ID
559+
:param security_group_id: Security Group ID to detach
560+
:return: Updated (or current) Port object
561+
"""
562+
conn = get_openstack_conn()
563+
current = conn.network.get_port(port_id)
564+
current_group_ids: list[str] = list(current.security_group_ids or [])
565+
if security_group_id not in current_group_ids:
566+
return self._convert_to_port_model(current)
567+
568+
updated_group_ids = [
569+
sg_id for sg_id in current_group_ids if sg_id != security_group_id
570+
]
571+
updated = conn.network.update_port(
572+
port_id, security_groups=updated_group_ids
573+
)
574+
return self._convert_to_port_model(updated)
575+
518576
def create_port(
519577
self,
520578
network_id: str,

tests/tools/test_network_tools.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,87 @@ def test_set_port_binding_and_admin_state(
869869
)
870870
assert res_toggle.is_admin_state_up is True
871871

872+
def test_add_remove_security_group_on_port(
873+
self, mock_openstack_connect_network
874+
):
875+
mock_conn = mock_openstack_connect_network
876+
877+
# current without sg-9
878+
current = Mock()
879+
current.id = "port-1"
880+
current.name = "p1"
881+
current.status = "ACTIVE"
882+
current.description = None
883+
current.project_id = None
884+
current.network_id = "net-1"
885+
current.admin_state_up = True
886+
current.is_admin_state_up = True
887+
current.device_id = None
888+
current.device_owner = None
889+
current.mac_address = "fa:16:3e:00:00:09"
890+
current.fixed_ips = []
891+
current.security_group_ids = ["sg-1", "sg-2"]
892+
mock_conn.network.get_port.return_value = current
893+
894+
# updated after add
895+
updated_add = Mock()
896+
updated_add.id = "port-1"
897+
updated_add.name = "p1"
898+
updated_add.status = "ACTIVE"
899+
updated_add.description = None
900+
updated_add.project_id = None
901+
updated_add.network_id = "net-1"
902+
updated_add.admin_state_up = True
903+
updated_add.is_admin_state_up = True
904+
updated_add.device_id = None
905+
updated_add.device_owner = None
906+
updated_add.mac_address = "fa:16:3e:00:00:09"
907+
updated_add.fixed_ips = []
908+
updated_add.security_group_ids = ["sg-1", "sg-2", "sg-9"]
909+
mock_conn.network.update_port.return_value = updated_add
910+
911+
tools = self.get_network_tools()
912+
res_add = tools.add_security_group_to_port("port-1", "sg-9")
913+
assert isinstance(res_add, Port)
914+
mock_conn.network.update_port.assert_called_with(
915+
"port-1", security_groups=["sg-1", "sg-2", "sg-9"]
916+
)
917+
918+
# idempotent add when sg already present
919+
mock_conn.network.get_port.return_value = updated_add
920+
res_add_again = tools.add_security_group_to_port("port-1", "sg-9")
921+
assert isinstance(res_add_again, Port)
922+
923+
# updated after remove
924+
updated_remove = Mock()
925+
updated_remove.id = "port-1"
926+
updated_remove.name = "p1"
927+
updated_remove.status = "ACTIVE"
928+
updated_remove.description = None
929+
updated_remove.project_id = None
930+
updated_remove.network_id = "net-1"
931+
updated_remove.admin_state_up = True
932+
updated_remove.is_admin_state_up = True
933+
updated_remove.device_id = None
934+
updated_remove.device_owner = None
935+
updated_remove.mac_address = "fa:16:3e:00:00:09"
936+
updated_remove.fixed_ips = []
937+
updated_remove.security_group_ids = ["sg-1", "sg-2"]
938+
mock_conn.network.update_port.return_value = updated_remove
939+
940+
res_remove = tools.remove_security_group_from_port("port-1", "sg-9")
941+
assert isinstance(res_remove, Port)
942+
mock_conn.network.update_port.assert_called_with(
943+
"port-1", security_groups=["sg-1", "sg-2"]
944+
)
945+
946+
# idempotent remove when sg not present
947+
mock_conn.network.get_port.return_value = updated_remove
948+
res_remove_again = tools.remove_security_group_from_port(
949+
"port-1", "sg-9"
950+
)
951+
assert isinstance(res_remove_again, Port)
952+
872953
def test_get_subnets_filters_and_has_gateway_true(
873954
self,
874955
mock_openstack_connect_network,

0 commit comments

Comments
 (0)