iflow-mcp_enuno-unifi-mcp-server 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/METADATA +1282 -0
  2. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/RECORD +81 -0
  3. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/WHEEL +4 -0
  4. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/entry_points.txt +2 -0
  5. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/licenses/LICENSE +201 -0
  6. src/__init__.py +3 -0
  7. src/__main__.py +6 -0
  8. src/api/__init__.py +5 -0
  9. src/api/client.py +727 -0
  10. src/api/site_manager_client.py +176 -0
  11. src/cache.py +483 -0
  12. src/config/__init__.py +5 -0
  13. src/config/config.py +321 -0
  14. src/main.py +2234 -0
  15. src/models/__init__.py +126 -0
  16. src/models/acl.py +41 -0
  17. src/models/backup.py +272 -0
  18. src/models/client.py +74 -0
  19. src/models/device.py +53 -0
  20. src/models/dpi.py +50 -0
  21. src/models/firewall_policy.py +123 -0
  22. src/models/firewall_zone.py +28 -0
  23. src/models/network.py +62 -0
  24. src/models/qos_profile.py +458 -0
  25. src/models/radius.py +141 -0
  26. src/models/reference_data.py +34 -0
  27. src/models/site.py +59 -0
  28. src/models/site_manager.py +120 -0
  29. src/models/topology.py +138 -0
  30. src/models/traffic_flow.py +137 -0
  31. src/models/traffic_matching_list.py +56 -0
  32. src/models/voucher.py +42 -0
  33. src/models/vpn.py +73 -0
  34. src/models/wan.py +48 -0
  35. src/models/zbf_matrix.py +49 -0
  36. src/resources/__init__.py +8 -0
  37. src/resources/clients.py +111 -0
  38. src/resources/devices.py +102 -0
  39. src/resources/networks.py +93 -0
  40. src/resources/site_manager.py +64 -0
  41. src/resources/sites.py +86 -0
  42. src/tools/__init__.py +25 -0
  43. src/tools/acls.py +328 -0
  44. src/tools/application.py +42 -0
  45. src/tools/backups.py +1173 -0
  46. src/tools/client_management.py +505 -0
  47. src/tools/clients.py +203 -0
  48. src/tools/device_control.py +325 -0
  49. src/tools/devices.py +354 -0
  50. src/tools/dpi.py +241 -0
  51. src/tools/dpi_tools.py +89 -0
  52. src/tools/firewall.py +417 -0
  53. src/tools/firewall_policies.py +430 -0
  54. src/tools/firewall_zones.py +515 -0
  55. src/tools/network_config.py +388 -0
  56. src/tools/networks.py +190 -0
  57. src/tools/port_forwarding.py +263 -0
  58. src/tools/qos.py +1070 -0
  59. src/tools/radius.py +763 -0
  60. src/tools/reference_data.py +107 -0
  61. src/tools/site_manager.py +466 -0
  62. src/tools/site_vpn.py +95 -0
  63. src/tools/sites.py +187 -0
  64. src/tools/topology.py +406 -0
  65. src/tools/traffic_flows.py +1062 -0
  66. src/tools/traffic_matching_lists.py +371 -0
  67. src/tools/vouchers.py +249 -0
  68. src/tools/vpn.py +76 -0
  69. src/tools/wans.py +30 -0
  70. src/tools/wifi.py +498 -0
  71. src/tools/zbf_matrix.py +326 -0
  72. src/utils/__init__.py +88 -0
  73. src/utils/audit.py +213 -0
  74. src/utils/exceptions.py +114 -0
  75. src/utils/helpers.py +159 -0
  76. src/utils/logger.py +105 -0
  77. src/utils/sanitize.py +244 -0
  78. src/utils/validators.py +160 -0
  79. src/webhooks/__init__.py +6 -0
  80. src/webhooks/handlers.py +196 -0
  81. src/webhooks/receiver.py +290 -0
@@ -0,0 +1,56 @@
1
+ """Traffic Matching List data models."""
2
+
3
+ from enum import Enum
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ class TrafficMatchingListType(str, Enum):
9
+ """Traffic matching list types."""
10
+
11
+ PORTS = "PORTS"
12
+ IPV4_ADDRESSES = "IPV4_ADDRESSES"
13
+ IPV6_ADDRESSES = "IPV6_ADDRESSES"
14
+
15
+
16
+ class TrafficMatchingList(BaseModel):
17
+ """UniFi Traffic Matching List configuration."""
18
+
19
+ id: str = Field(..., description="Traffic matching list ID", alias="_id")
20
+ type: TrafficMatchingListType = Field(..., description="List type")
21
+ name: str = Field(..., description="List name")
22
+ items: list[str] = Field(default_factory=list, description="List items (ports, IPs, etc.)")
23
+ site_id: str | None = Field(None, description="Site ID")
24
+
25
+ model_config = ConfigDict(
26
+ populate_by_name=True,
27
+ use_enum_values=True,
28
+ json_schema_extra={
29
+ "example": {
30
+ "_id": "507f191e810c19729de860ea",
31
+ "type": "PORTS",
32
+ "name": "Common Web Ports",
33
+ "items": ["80", "443", "8080", "8443"],
34
+ }
35
+ },
36
+ )
37
+
38
+
39
+ class TrafficMatchingListCreate(BaseModel):
40
+ """Request model for creating traffic matching list."""
41
+
42
+ type: TrafficMatchingListType = Field(..., description="List type")
43
+ name: str = Field(..., description="List name", min_length=1, max_length=128)
44
+ items: list[str] = Field(..., description="List items (non-empty)", min_length=1)
45
+
46
+ model_config = ConfigDict(use_enum_values=True)
47
+
48
+
49
+ class TrafficMatchingListUpdate(BaseModel):
50
+ """Request model for updating traffic matching list."""
51
+
52
+ type: TrafficMatchingListType | None = Field(None, description="List type")
53
+ name: str | None = Field(None, description="List name", min_length=1, max_length=128)
54
+ items: list[str] | None = Field(None, description="List items", min_length=1)
55
+
56
+ model_config = ConfigDict(use_enum_values=True)
src/models/voucher.py ADDED
@@ -0,0 +1,42 @@
1
+ """Hotspot voucher models."""
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ class Voucher(BaseModel):
9
+ """Hotspot voucher model."""
10
+
11
+ id: str = Field(..., alias="_id", description="Voucher identifier")
12
+ site_id: str = Field(..., description="Site identifier")
13
+ code: str = Field(..., description="Voucher code")
14
+
15
+ # Usage status
16
+ status: str = Field(..., description="Voucher status (unused/used/expired)")
17
+ used: int = Field(0, description="Number of times used")
18
+ quota: int = Field(1, description="Number of times voucher can be used")
19
+
20
+ # Time configuration
21
+ duration: int = Field(..., description="Duration in seconds")
22
+ start_time: datetime | None = Field(None, description="When voucher was first used")
23
+ end_time: datetime | None = Field(None, description="When voucher expires")
24
+ create_time: datetime = Field(..., description="When voucher was created")
25
+
26
+ # Bandwidth limits
27
+ upload_limit_kbps: int | None = Field(
28
+ None, alias="qos_rate_max_up", description="Upload speed limit in kbps"
29
+ )
30
+ download_limit_kbps: int | None = Field(
31
+ None, alias="qos_rate_max_down", description="Download speed limit in kbps"
32
+ )
33
+ upload_quota_mb: int | None = Field(
34
+ None, alias="qos_usage_quota", description="Upload quota in MB"
35
+ )
36
+ download_quota_mb: int | None = Field(None, description="Download quota in MB")
37
+
38
+ # Additional metadata
39
+ note: str | None = Field(None, description="Admin notes")
40
+ admin_name: str | None = Field(None, description="Admin who created voucher")
41
+
42
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
src/models/vpn.py ADDED
@@ -0,0 +1,73 @@
1
+ """VPN data models."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class SiteToSiteVPN(BaseModel):
7
+ """UniFi Site-to-Site IPsec VPN configuration."""
8
+
9
+ id: str = Field(..., description="VPN ID", alias="_id")
10
+ name: str = Field(..., description="VPN name")
11
+ enabled: bool = Field(True, description="Whether VPN is enabled")
12
+ vpn_type: str = Field("ipsec-vpn", description="VPN type")
13
+ purpose: str = Field("site-vpn", description="Network purpose")
14
+
15
+ # IPsec peer settings
16
+ ipsec_peer_ip: str | None = Field(None, description="Remote peer IP address")
17
+ ipsec_local_ip: str | None = Field(None, description="Local WAN IP address")
18
+ remote_vpn_subnets: list[str] | None = Field(None, description="Remote subnets")
19
+ x_ipsec_pre_shared_key: str | None = Field(None, description="Pre-shared key")
20
+
21
+ # IKE settings
22
+ ipsec_key_exchange: str | None = Field(None, description="IKE version (ikev1/ikev2)")
23
+ ipsec_ike_encryption: str | None = Field(None, description="IKE encryption")
24
+ ipsec_ike_hash: str | None = Field(None, description="IKE hash algorithm")
25
+ ipsec_ike_dh_group: int | None = Field(None, description="IKE DH group")
26
+ ipsec_ike_lifetime: int | None = Field(None, description="IKE lifetime in seconds")
27
+
28
+ # ESP settings
29
+ ipsec_esp_encryption: str | None = Field(None, description="ESP encryption")
30
+ ipsec_esp_hash: str | None = Field(None, description="ESP hash algorithm")
31
+ ipsec_esp_dh_group: int | None = Field(None, description="ESP DH group")
32
+ ipsec_esp_lifetime: int | None = Field(None, description="ESP lifetime in seconds")
33
+ ipsec_pfs: bool | None = Field(None, description="Perfect Forward Secrecy")
34
+
35
+ # Other settings
36
+ ipsec_profile: str | None = Field(None, description="IPsec profile")
37
+ ipsec_interface: str | None = Field(None, description="WAN interface")
38
+ ipsec_dynamic_routing: bool | None = Field(None, description="Dynamic routing enabled")
39
+ route_distance: int | None = Field(None, description="Route distance/metric")
40
+ site_id: str | None = Field(None, description="Site ID")
41
+
42
+ model_config = ConfigDict(populate_by_name=True)
43
+
44
+
45
+ class VPNTunnel(BaseModel):
46
+ """UniFi Site-to-Site VPN Tunnel."""
47
+
48
+ id: str = Field(..., description="VPN tunnel ID", alias="_id")
49
+ name: str = Field(..., description="Tunnel name")
50
+ enabled: bool | None = Field(None, description="Whether tunnel is enabled")
51
+ peer_address: str | None = Field(None, description="Remote peer address")
52
+ local_network: str | None = Field(None, description="Local network CIDR")
53
+ remote_network: str | None = Field(None, description="Remote network CIDR")
54
+ status: str | None = Field(None, description="Connection status")
55
+ ipsec_profile: str | None = Field(None, description="IPSec profile name")
56
+ site_id: str | None = Field(None, description="Site ID")
57
+
58
+ model_config = ConfigDict(populate_by_name=True)
59
+
60
+
61
+ class VPNServer(BaseModel):
62
+ """UniFi VPN Server configuration."""
63
+
64
+ id: str = Field(..., description="VPN server ID", alias="_id")
65
+ name: str = Field(..., description="Server name")
66
+ enabled: bool | None = Field(None, description="Whether server is enabled")
67
+ server_type: str | None = Field(None, description="VPN type (L2TP, PPTP, etc.)")
68
+ network: str | None = Field(None, description="VPN client network")
69
+ dns_servers: list[str] | None = Field(None, description="DNS servers for clients")
70
+ max_connections: int | None = Field(None, description="Maximum concurrent connections")
71
+ site_id: str | None = Field(None, description="Site ID")
72
+
73
+ model_config = ConfigDict(populate_by_name=True)
src/models/wan.py ADDED
@@ -0,0 +1,48 @@
1
+ """WAN connection models."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class WANConnection(BaseModel):
7
+ """WAN connection model."""
8
+
9
+ id: str = Field(..., alias="_id", description="WAN connection identifier")
10
+ site_id: str = Field(..., description="Site identifier")
11
+ name: str = Field(..., description="WAN connection name")
12
+
13
+ # Connection type
14
+ wan_type: str = Field(..., description="WAN type (dhcp/static/pppoe)")
15
+ interface: str = Field(..., description="Physical interface (eth0/eth1/etc)")
16
+
17
+ # IP configuration
18
+ ip_address: str | None = Field(None, description="WAN IP address")
19
+ netmask: str | None = Field(None, description="Subnet mask")
20
+ gateway: str | None = Field(None, description="Gateway IP")
21
+ dns_servers: list[str] = Field(default_factory=list, description="DNS server IPs")
22
+
23
+ # Connection status
24
+ status: str = Field(..., description="Connection status (online/offline/connecting)")
25
+ uptime: int | None = Field(None, description="Connection uptime in seconds")
26
+
27
+ # Statistics
28
+ rx_bytes: int | None = Field(None, description="Received bytes")
29
+ tx_bytes: int | None = Field(None, description="Transmitted bytes")
30
+ rx_packets: int | None = Field(None, description="Received packets")
31
+ tx_packets: int | None = Field(None, description="Transmitted packets")
32
+ rx_errors: int | None = Field(None, description="Receive errors")
33
+ tx_errors: int | None = Field(None, description="Transmit errors")
34
+
35
+ # Speed and link
36
+ speed: int | None = Field(None, description="Link speed in Mbps")
37
+ full_duplex: bool | None = Field(None, description="Full duplex status")
38
+
39
+ # Failover configuration
40
+ failover_priority: int | None = Field(
41
+ None, description="Failover priority (lower = higher priority)"
42
+ )
43
+ is_backup: bool = Field(False, description="Whether this is a backup WAN")
44
+
45
+ # ISP information
46
+ isp_name: str | None = Field(None, description="ISP name")
47
+
48
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
@@ -0,0 +1,49 @@
1
+ """Zone-Based Firewall matrix models."""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class ZonePolicy(BaseModel):
9
+ """Policy between two zones."""
10
+
11
+ source_zone_id: str = Field(..., description="Source zone identifier")
12
+ destination_zone_id: str = Field(..., description="Destination zone identifier")
13
+ action: Literal["allow", "deny"] = Field(..., description="Policy action")
14
+ description: str | None = Field(None, description="Policy description")
15
+ priority: int | None = Field(None, description="Policy priority")
16
+ enabled: bool = Field(True, description="Whether policy is enabled")
17
+
18
+
19
+ class ApplicationBlockRule(BaseModel):
20
+ """Application blocking rule for a zone."""
21
+
22
+ zone_id: str = Field(..., description="Zone identifier")
23
+ application_id: str = Field(..., description="DPI application identifier")
24
+ application_name: str | None = Field(None, description="Application name")
25
+ action: Literal["block", "allow"] = Field(..., description="Block or allow action")
26
+ enabled: bool = Field(True, description="Whether rule is enabled")
27
+ description: str | None = Field(None, description="Rule description")
28
+
29
+
30
+ class ZonePolicyMatrix(BaseModel):
31
+ """Matrix of zone-to-zone policies."""
32
+
33
+ site_id: str = Field(..., description="Site identifier")
34
+ zones: list[str] = Field(..., description="List of zone IDs in the matrix")
35
+ policies: list[ZonePolicy] = Field(
36
+ default_factory=list, description="List of inter-zone policies"
37
+ )
38
+ default_policy: Literal["allow", "deny"] = Field(
39
+ "allow", description="Default policy for unconfigured zone pairs"
40
+ )
41
+
42
+
43
+ class ZoneNetworkAssignment(BaseModel):
44
+ """Network assignment to a zone."""
45
+
46
+ zone_id: str = Field(..., description="Zone identifier")
47
+ network_id: str = Field(..., description="Network identifier")
48
+ network_name: str | None = Field(None, description="Network name")
49
+ assigned_at: str | None = Field(None, description="ISO timestamp of assignment")
@@ -0,0 +1,8 @@
1
+ """MCP resources for UniFi MCP Server."""
2
+
3
+ from .clients import ClientsResource
4
+ from .devices import DevicesResource
5
+ from .networks import NetworksResource
6
+ from .sites import SitesResource
7
+
8
+ __all__ = ["SitesResource", "DevicesResource", "ClientsResource", "NetworksResource"]
@@ -0,0 +1,111 @@
1
+ """Clients MCP resource implementation."""
2
+
3
+ from ..api import UniFiClient
4
+ from ..config import Settings
5
+ from ..models import Client
6
+ from ..utils import get_logger, validate_limit_offset, validate_site_id
7
+
8
+
9
+ class ClientsResource:
10
+ """MCP resource for UniFi network clients."""
11
+
12
+ def __init__(self, settings: Settings) -> None:
13
+ """Initialize clients resource.
14
+
15
+ Args:
16
+ settings: Application settings
17
+ """
18
+ self.settings = settings
19
+ self.logger = get_logger(__name__, settings.log_level)
20
+
21
+ async def list_clients(
22
+ self,
23
+ site_id: str,
24
+ limit: int | None = None,
25
+ offset: int | None = None,
26
+ active_only: bool = False,
27
+ ) -> list[Client]:
28
+ """List all clients for a specific site.
29
+
30
+ Args:
31
+ site_id: Site identifier
32
+ limit: Maximum number of clients to return
33
+ offset: Number of clients to skip
34
+ active_only: If True, only return currently connected clients
35
+
36
+ Returns:
37
+ List of Client objects
38
+ """
39
+ site_id = validate_site_id(site_id)
40
+ limit, offset = validate_limit_offset(limit, offset)
41
+
42
+ async with UniFiClient(self.settings) as client:
43
+ await client.authenticate()
44
+
45
+ # Fetch clients from API
46
+ # Use /sta for active clients or /stat/alluser for all
47
+ endpoint = (
48
+ f"/ea/sites/{site_id}/sta" if active_only else f"/ea/sites/{site_id}/stat/alluser"
49
+ )
50
+
51
+ response = await client.get(endpoint)
52
+
53
+ # Extract clients data
54
+ clients_data = response.get("data", [])
55
+
56
+ # Apply pagination
57
+ paginated_data = clients_data[offset : offset + limit]
58
+
59
+ # Parse into Client models
60
+ clients = [Client(**client_data) for client_data in paginated_data]
61
+
62
+ self.logger.info(
63
+ f"Retrieved {len(clients)} clients for site '{site_id}' "
64
+ f"(active_only={active_only}, offset={offset}, limit={limit})"
65
+ )
66
+
67
+ return clients
68
+
69
+ async def filter_by_connection(
70
+ self,
71
+ site_id: str,
72
+ is_wired: bool | None = None,
73
+ limit: int | None = None,
74
+ offset: int | None = None,
75
+ ) -> list[Client]:
76
+ """Filter clients by connection type.
77
+
78
+ Args:
79
+ site_id: Site identifier
80
+ is_wired: Filter by wired (True) or wireless (False)
81
+ limit: Maximum number of clients to return
82
+ offset: Number of clients to skip
83
+
84
+ Returns:
85
+ Filtered list of Client objects
86
+ """
87
+ clients = await self.list_clients(site_id, limit=1000, offset=0, active_only=True)
88
+
89
+ # Filter by connection type
90
+ if is_wired is not None:
91
+ filtered = [c for c in clients if c.is_wired == is_wired]
92
+ else:
93
+ filtered = clients
94
+
95
+ # Apply pagination to filtered results
96
+ limit, offset = validate_limit_offset(limit, offset)
97
+ return filtered[offset : offset + limit]
98
+
99
+ def get_uri(self, site_id: str, client_mac: str | None = None) -> str:
100
+ """Get the MCP resource URI.
101
+
102
+ Args:
103
+ site_id: Site identifier
104
+ client_mac: Optional client MAC address
105
+
106
+ Returns:
107
+ Resource URI
108
+ """
109
+ if client_mac:
110
+ return f"sites://{site_id}/clients/{client_mac}"
111
+ return f"sites://{site_id}/clients"
@@ -0,0 +1,102 @@
1
+ """Devices MCP resource implementation."""
2
+
3
+ from ..api import UniFiClient
4
+ from ..config import Settings
5
+ from ..models import Device
6
+ from ..utils import get_logger, validate_limit_offset, validate_site_id
7
+
8
+
9
+ class DevicesResource:
10
+ """MCP resource for UniFi devices."""
11
+
12
+ def __init__(self, settings: Settings) -> None:
13
+ """Initialize devices resource.
14
+
15
+ Args:
16
+ settings: Application settings
17
+ """
18
+ self.settings = settings
19
+ self.logger = get_logger(__name__, settings.log_level)
20
+
21
+ async def list_devices(
22
+ self, site_id: str, limit: int | None = None, offset: int | None = None
23
+ ) -> list[Device]:
24
+ """List all devices for a specific site.
25
+
26
+ Args:
27
+ site_id: Site identifier
28
+ limit: Maximum number of devices to return
29
+ offset: Number of devices to skip
30
+
31
+ Returns:
32
+ List of Device objects
33
+ """
34
+ site_id = validate_site_id(site_id)
35
+ limit, offset = validate_limit_offset(limit, offset)
36
+
37
+ async with UniFiClient(self.settings) as client:
38
+ await client.authenticate()
39
+
40
+ # Fetch devices from API
41
+ response = await client.get(f"/ea/sites/{site_id}/devices")
42
+
43
+ # Extract devices data
44
+ devices_data = response.get("data", [])
45
+
46
+ # Apply pagination
47
+ paginated_data = devices_data[offset : offset + limit]
48
+
49
+ # Parse into Device models
50
+ devices = [Device(**device) for device in paginated_data]
51
+
52
+ self.logger.info(
53
+ f"Retrieved {len(devices)} devices for site '{site_id}' "
54
+ f"(offset={offset}, limit={limit})"
55
+ )
56
+
57
+ return devices
58
+
59
+ async def filter_by_type(
60
+ self,
61
+ site_id: str,
62
+ device_type: str,
63
+ limit: int | None = None,
64
+ offset: int | None = None,
65
+ ) -> list[Device]:
66
+ """Filter devices by type.
67
+
68
+ Args:
69
+ site_id: Site identifier
70
+ device_type: Device type filter (ap, switch, gateway)
71
+ limit: Maximum number of devices to return
72
+ offset: Number of devices to skip
73
+
74
+ Returns:
75
+ Filtered list of Device objects
76
+ """
77
+ devices = await self.list_devices(site_id, limit=1000, offset=0)
78
+
79
+ # Filter by type
80
+ filtered = [
81
+ d
82
+ for d in devices
83
+ if d.type.lower() == device_type.lower() or device_type.lower() in d.model.lower()
84
+ ]
85
+
86
+ # Apply pagination to filtered results
87
+ limit, offset = validate_limit_offset(limit, offset)
88
+ return filtered[offset : offset + limit]
89
+
90
+ def get_uri(self, site_id: str, device_id: str | None = None) -> str:
91
+ """Get the MCP resource URI.
92
+
93
+ Args:
94
+ site_id: Site identifier
95
+ device_id: Optional device ID
96
+
97
+ Returns:
98
+ Resource URI
99
+ """
100
+ if device_id:
101
+ return f"sites://{site_id}/devices/{device_id}"
102
+ return f"sites://{site_id}/devices"
@@ -0,0 +1,93 @@
1
+ """Networks MCP resource implementation."""
2
+
3
+ from ..api import UniFiClient
4
+ from ..config import Settings
5
+ from ..models import Network
6
+ from ..utils import get_logger, validate_limit_offset, validate_site_id
7
+
8
+
9
+ class NetworksResource:
10
+ """MCP resource for UniFi networks."""
11
+
12
+ def __init__(self, settings: Settings) -> None:
13
+ """Initialize networks resource.
14
+
15
+ Args:
16
+ settings: Application settings
17
+ """
18
+ self.settings = settings
19
+ self.logger = get_logger(__name__, settings.log_level)
20
+
21
+ async def list_networks(
22
+ self, site_id: str, limit: int | None = None, offset: int | None = None
23
+ ) -> list[Network]:
24
+ """List all networks for a specific site.
25
+
26
+ Args:
27
+ site_id: Site identifier
28
+ limit: Maximum number of networks to return
29
+ offset: Number of networks to skip
30
+
31
+ Returns:
32
+ List of Network objects
33
+ """
34
+ site_id = validate_site_id(site_id)
35
+ limit, offset = validate_limit_offset(limit, offset)
36
+
37
+ async with UniFiClient(self.settings) as client:
38
+ await client.authenticate()
39
+
40
+ # Fetch networks from API
41
+ response = await client.get(f"/ea/sites/{site_id}/rest/networkconf")
42
+
43
+ # Extract networks data
44
+ networks_data = response.get("data", [])
45
+
46
+ # Apply pagination
47
+ paginated_data = networks_data[offset : offset + limit]
48
+
49
+ # Parse into Network models
50
+ networks = [Network(**network) for network in paginated_data]
51
+
52
+ self.logger.info(
53
+ f"Retrieved {len(networks)} networks for site '{site_id}' "
54
+ f"(offset={offset}, limit={limit})"
55
+ )
56
+
57
+ return networks
58
+
59
+ async def list_vlans(
60
+ self, site_id: str, limit: int | None = None, offset: int | None = None
61
+ ) -> list[Network]:
62
+ """List all VLANs for a specific site.
63
+
64
+ Args:
65
+ site_id: Site identifier
66
+ limit: Maximum number of VLANs to return
67
+ offset: Number of VLANs to skip
68
+
69
+ Returns:
70
+ List of Network objects that are VLANs
71
+ """
72
+ networks = await self.list_networks(site_id, limit=1000, offset=0)
73
+
74
+ # Filter for networks with VLAN configuration
75
+ vlans = [n for n in networks if n.vlan_id is not None]
76
+
77
+ # Apply pagination to filtered results
78
+ limit, offset = validate_limit_offset(limit, offset)
79
+ return vlans[offset : offset + limit]
80
+
81
+ def get_uri(self, site_id: str, network_id: str | None = None) -> str:
82
+ """Get the MCP resource URI.
83
+
84
+ Args:
85
+ site_id: Site identifier
86
+ network_id: Optional network ID
87
+
88
+ Returns:
89
+ Resource URI
90
+ """
91
+ if network_id:
92
+ return f"sites://{site_id}/networks/{network_id}"
93
+ return f"sites://{site_id}/networks"
@@ -0,0 +1,64 @@
1
+ """Site Manager API resources."""
2
+
3
+ from ..api.site_manager_client import SiteManagerClient
4
+ from ..config import Settings
5
+ from ..utils import get_logger
6
+
7
+ logger = get_logger(__name__)
8
+
9
+
10
+ class SiteManagerResource:
11
+ """Resource handler for Site Manager API."""
12
+
13
+ def __init__(self, settings: Settings) -> None:
14
+ """Initialize Site Manager resource handler.
15
+
16
+ Args:
17
+ settings: Application settings
18
+ """
19
+ self.settings = settings
20
+ self.logger = get_logger(__name__, settings.log_level)
21
+
22
+ async def get_all_sites(self) -> str:
23
+ """Get all sites across organization.
24
+
25
+ Returns:
26
+ JSON string of sites list
27
+ """
28
+ if not self.settings.site_manager_enabled:
29
+ return "Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true"
30
+
31
+ async with SiteManagerClient(self.settings) as client:
32
+ response = await client.list_sites()
33
+ sites = response.get("data", response.get("sites", []))
34
+ return "\n".join(
35
+ [f"Site: {s.get('name', 'Unknown')} ({s.get('id', 'unknown')})" for s in sites]
36
+ )
37
+
38
+ async def get_health_metrics(self) -> str:
39
+ """Get cross-site health metrics.
40
+
41
+ Returns:
42
+ JSON string of health metrics
43
+ """
44
+ if not self.settings.site_manager_enabled:
45
+ return "Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true"
46
+
47
+ async with SiteManagerClient(self.settings) as client:
48
+ response = await client.get_site_health()
49
+ health_data = response.get("data", response)
50
+ return f"Health Status: {health_data}"
51
+
52
+ async def get_internet_health_status(self) -> str:
53
+ """Get internet connectivity status.
54
+
55
+ Returns:
56
+ JSON string of internet health
57
+ """
58
+ if not self.settings.site_manager_enabled:
59
+ return "Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true"
60
+
61
+ async with SiteManagerClient(self.settings) as client:
62
+ response = await client.get_internet_health()
63
+ health_data = response.get("data", response)
64
+ return f"Internet Health: {health_data}"