Skip to content

Commit d3fa146

Browse files
platanus-krhalucinor
authored andcommitted
feat: Add network router CRUD tool (#79)
1 parent e32bb70 commit d3fa146

File tree

3 files changed

+537
-30
lines changed

3 files changed

+537
-30
lines changed

src/openstack_mcp_server/tools/network_tools.py

Lines changed: 221 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
from fastmcp import FastMCP
22

33
from .base import get_openstack_conn
4+
from .request.network import (
5+
ExternalGatewayInfo,
6+
Route,
7+
)
48
from .response.network import (
59
FloatingIP,
610
Network,
711
Port,
12+
Router,
813
Subnet,
914
)
1015

@@ -42,6 +47,11 @@ def register_tools(self, mcp: FastMCP):
4247
mcp.tool()(self.update_floating_ip)
4348
mcp.tool()(self.create_floating_ips_bulk)
4449
mcp.tool()(self.assign_first_available_floating_ip)
50+
mcp.tool()(self.get_routers)
51+
mcp.tool()(self.create_router)
52+
mcp.tool()(self.get_router_detail)
53+
mcp.tool()(self.update_router)
54+
mcp.tool()(self.delete_router)
4555

4656
def get_networks(
4757
self,
@@ -63,9 +73,9 @@ def get_networks(
6373
filters["status"] = status_filter.upper()
6474

6575
if shared_only:
66-
filters["shared"] = True
76+
filters["is_shared"] = True
6777

68-
networks = conn.list_networks(filters=filters)
78+
networks = conn.network.networks(**filters)
6979

7080
return [
7181
self._convert_to_network_model(network) for network in networks
@@ -80,6 +90,7 @@ def create_network(
8090
provider_network_type: str | None = None,
8191
provider_physical_network: str | None = None,
8292
provider_segmentation_id: int | None = None,
93+
project_id: str | None = None,
8394
) -> Network:
8495
"""
8596
Create a new Network.
@@ -107,6 +118,9 @@ def create_network(
107118
if provider_network_type:
108119
network_args["provider_network_type"] = provider_network_type
109120

121+
if project_id:
122+
network_args["project_id"] = project_id
123+
110124
if provider_physical_network:
111125
network_args["provider_physical_network"] = (
112126
provider_physical_network
@@ -245,7 +259,7 @@ def get_subnets(
245259
filters["project_id"] = project_id
246260
if is_dhcp_enabled is not None:
247261
filters["enable_dhcp"] = is_dhcp_enabled
248-
subnets = conn.list_subnets(filters=filters)
262+
subnets = conn.network.subnets(**filters)
249263
if has_gateway is not None:
250264
subnets = [
251265
s for s in subnets if (s.gateway_ip is not None) == has_gateway
@@ -440,7 +454,9 @@ def get_ports(
440454
filters["device_id"] = device_id
441455
if network_id:
442456
filters["network_id"] = network_id
443-
ports = conn.list_ports(filters=filters)
457+
458+
ports = conn.network.ports(**filters)
459+
444460
return [self._convert_to_port_model(port) for port in ports]
445461

446462
def get_port_allowed_address_pairs(self, port_id: str) -> list[dict]:
@@ -860,3 +876,204 @@ def _convert_to_floating_ip_model(self, openstack_ip) -> FloatingIP:
860876
port_id=openstack_ip.port_id,
861877
router_id=openstack_ip.router_id,
862878
)
879+
880+
def get_routers(
881+
self,
882+
status_filter: str | None = None,
883+
project_id: str | None = None,
884+
is_admin_state_up: bool | None = None,
885+
) -> list[Router]:
886+
"""
887+
Get the list of Routers with optional filtering.
888+
:param status_filter: Filter by router status (e.g., `ACTIVE`, `DOWN`)
889+
:param project_id: Filter by project ID
890+
:param is_admin_state_up: Filter by admin state
891+
:return: List of Router objects
892+
"""
893+
conn = get_openstack_conn()
894+
filters: dict = {}
895+
if status_filter:
896+
filters["status"] = status_filter.upper()
897+
if project_id:
898+
filters["project_id"] = project_id
899+
if is_admin_state_up is not None:
900+
filters["admin_state_up"] = is_admin_state_up
901+
# Do not pass unsupported filters (e.g., status) to the server.
902+
server_filters = self._sanitize_server_filters(filters)
903+
routers = conn.network.routers(**server_filters)
904+
905+
router_models = [self._convert_to_router_model(r) for r in routers]
906+
if status_filter:
907+
status_upper = status_filter.upper()
908+
router_models = [
909+
r
910+
for r in router_models
911+
if (r.status or "").upper() == status_upper
912+
]
913+
return router_models
914+
915+
def create_router(
916+
self,
917+
name: str | None = None,
918+
description: str | None = None,
919+
is_admin_state_up: bool = True,
920+
is_distributed: bool | None = None,
921+
project_id: str | None = None,
922+
external_gateway_info: ExternalGatewayInfo | None = None,
923+
) -> Router:
924+
"""
925+
Create a new Router.
926+
Typical use-cases:
927+
- Create basic router: name="r1" (defaults to admin_state_up=True)
928+
- Create distributed router: is_distributed=True
929+
- Create with external gateway for north-south traffic:
930+
external_gateway_info={"network_id": "ext-net", "enable_snat": True,
931+
"external_fixed_ips": [{"subnet_id": "ext-subnet", "ip_address": "203.0.113.10"}]}
932+
- Create with project ownership: project_id="proj-1"
933+
Notes:
934+
- external_gateway_info should follow Neutron schema: at minimum include
935+
"network_id"; optional keys include "enable_snat" and "external_fixed_ips".
936+
:param name: Router name
937+
:param description: Router description
938+
:param is_admin_state_up: Administrative state
939+
:param is_distributed: Distributed router flag
940+
:param project_id: Project ownership
941+
:param external_gateway_info: External gateway info dict
942+
:return: Created Router object
943+
"""
944+
conn = get_openstack_conn()
945+
router_args: dict = {"admin_state_up": is_admin_state_up}
946+
if name is not None:
947+
router_args["name"] = name
948+
if description is not None:
949+
router_args["description"] = description
950+
if is_distributed is not None:
951+
router_args["distributed"] = is_distributed
952+
if project_id is not None:
953+
router_args["project_id"] = project_id
954+
if external_gateway_info is not None:
955+
router_args["external_gateway_info"] = (
956+
external_gateway_info.model_dump(exclude_none=True)
957+
)
958+
router = conn.network.create_router(**router_args)
959+
return self._convert_to_router_model(router)
960+
961+
def get_router_detail(self, router_id: str) -> Router:
962+
"""
963+
Get detailed information about a specific Router.
964+
:param router_id: ID of the router to retrieve
965+
:return: Router details
966+
"""
967+
conn = get_openstack_conn()
968+
router = conn.network.get_router(router_id)
969+
return self._convert_to_router_model(router)
970+
971+
def update_router(
972+
self,
973+
router_id: str,
974+
name: str | None = None,
975+
description: str | None = None,
976+
is_admin_state_up: bool | None = None,
977+
is_distributed: bool | None = None,
978+
external_gateway_info: ExternalGatewayInfo | None = None,
979+
clear_external_gateway: bool = False,
980+
routes: list[Route] | None = None,
981+
) -> Router:
982+
"""
983+
Update Router attributes atomically. Only provided parameters are changed;
984+
omitted parameters remain untouched.
985+
Typical use-cases:
986+
- Rename and change description: name="r-new", description="d".
987+
- Toggle admin state: read current via get_router_detail(); pass inverted bool to is_admin_state_up.
988+
- Set distributed flag: is_distributed=True or False.
989+
- Set external gateway: external_gateway_info={"network_id": "ext-net", "enable_snat": True, "external_fixed_ips": [...]}.
990+
- Clear external gateway: clear_external_gateway=True (takes precedence over external_gateway_info).
991+
- Replace static routes: routes=[{"destination": "192.0.2.0/24", "nexthop": "10.0.0.1"}]. Pass [] to remove all routes.
992+
Notes:
993+
- For list-typed fields (routes), the provided list replaces the entire list on the server.
994+
- To clear external gateway, use clear_external_gateway=True. If both provided, clear_external_gateway takes precedence.
995+
:param router_id: ID of the router to update
996+
:param name: New router name
997+
:param description: New router description
998+
:param is_admin_state_up: Administrative state
999+
:param is_distributed: Distributed router flag
1000+
:param external_gateway_info: External gateway info dict to set
1001+
:param clear_external_gateway: If True, clear external gateway (set to None)
1002+
:param routes: Static routes (replaces entire list)
1003+
:return: Updated Router object
1004+
"""
1005+
conn = get_openstack_conn()
1006+
update_args: dict = {}
1007+
if name is not None:
1008+
update_args["name"] = name
1009+
if description is not None:
1010+
update_args["description"] = description
1011+
if is_admin_state_up is not None:
1012+
update_args["admin_state_up"] = is_admin_state_up
1013+
if is_distributed is not None:
1014+
update_args["distributed"] = is_distributed
1015+
if clear_external_gateway:
1016+
update_args["external_gateway_info"] = None
1017+
elif external_gateway_info is not None:
1018+
update_args["external_gateway_info"] = (
1019+
external_gateway_info.model_dump(exclude_none=True)
1020+
)
1021+
if routes is not None:
1022+
update_args["routes"] = [
1023+
r.model_dump(exclude_none=True) for r in routes
1024+
]
1025+
if not update_args:
1026+
current = conn.network.get_router(router_id)
1027+
return self._convert_to_router_model(current)
1028+
router = conn.network.update_router(router_id, **update_args)
1029+
return self._convert_to_router_model(router)
1030+
1031+
def delete_router(self, router_id: str) -> None:
1032+
"""
1033+
Delete a Router.
1034+
:param router_id: ID of the router to delete
1035+
:return: None
1036+
"""
1037+
conn = get_openstack_conn()
1038+
conn.network.delete_router(router_id, ignore_missing=False)
1039+
return None
1040+
1041+
def _convert_to_router_model(self, openstack_router) -> Router:
1042+
"""
1043+
Convert an OpenStack Router object to a Router pydantic model.
1044+
:param openstack_router: OpenStack router object
1045+
:return: Pydantic Router model
1046+
"""
1047+
return Router(
1048+
id=openstack_router.id,
1049+
name=getattr(openstack_router, "name", None),
1050+
status=getattr(openstack_router, "status", None),
1051+
description=getattr(openstack_router, "description", None),
1052+
project_id=getattr(openstack_router, "project_id", None),
1053+
is_admin_state_up=getattr(
1054+
openstack_router, "is_admin_state_up", None
1055+
),
1056+
external_gateway_info=getattr(
1057+
openstack_router, "external_gateway_info", None
1058+
),
1059+
is_distributed=getattr(openstack_router, "is_distributed", None),
1060+
is_ha=getattr(openstack_router, "is_ha", None),
1061+
routes=getattr(openstack_router, "routes", None),
1062+
)
1063+
1064+
def _sanitize_server_filters(self, filters: dict) -> dict:
1065+
"""
1066+
Remove unsupported query params before sending to Neutron.
1067+
1068+
Currently removed keys:
1069+
- "status": not universally supported for server-side filtering
1070+
1071+
:param filters: original filter dict
1072+
:return: cleaned filter dict safe for server query
1073+
"""
1074+
if not filters:
1075+
return {}
1076+
attrs = dict(filters)
1077+
# Remove client-only or unsupported filters
1078+
attrs.pop("status", None)
1079+
return attrs
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from pydantic import BaseModel
2+
3+
4+
class Route(BaseModel):
5+
"""Static route for a router."""
6+
7+
destination: str
8+
nexthop: str
9+
10+
11+
class ExternalFixedIP(BaseModel):
12+
"""External fixed IP assignment for router gateway."""
13+
14+
subnet_id: str | None = None
15+
ip_address: str | None = None
16+
17+
18+
class ExternalGatewayInfo(BaseModel):
19+
"""External gateway information for a router.
20+
At minimum include `network_id`. Optionally include `enable_snat` and
21+
`external_fixed_ips`.
22+
"""
23+
24+
network_id: str
25+
enable_snat: bool | None = None
26+
external_fixed_ips: list[ExternalFixedIP] | None = None

0 commit comments

Comments
 (0)