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.
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/METADATA +1282 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/RECORD +81 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/WHEEL +4 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/entry_points.txt +2 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/licenses/LICENSE +201 -0
- src/__init__.py +3 -0
- src/__main__.py +6 -0
- src/api/__init__.py +5 -0
- src/api/client.py +727 -0
- src/api/site_manager_client.py +176 -0
- src/cache.py +483 -0
- src/config/__init__.py +5 -0
- src/config/config.py +321 -0
- src/main.py +2234 -0
- src/models/__init__.py +126 -0
- src/models/acl.py +41 -0
- src/models/backup.py +272 -0
- src/models/client.py +74 -0
- src/models/device.py +53 -0
- src/models/dpi.py +50 -0
- src/models/firewall_policy.py +123 -0
- src/models/firewall_zone.py +28 -0
- src/models/network.py +62 -0
- src/models/qos_profile.py +458 -0
- src/models/radius.py +141 -0
- src/models/reference_data.py +34 -0
- src/models/site.py +59 -0
- src/models/site_manager.py +120 -0
- src/models/topology.py +138 -0
- src/models/traffic_flow.py +137 -0
- src/models/traffic_matching_list.py +56 -0
- src/models/voucher.py +42 -0
- src/models/vpn.py +73 -0
- src/models/wan.py +48 -0
- src/models/zbf_matrix.py +49 -0
- src/resources/__init__.py +8 -0
- src/resources/clients.py +111 -0
- src/resources/devices.py +102 -0
- src/resources/networks.py +93 -0
- src/resources/site_manager.py +64 -0
- src/resources/sites.py +86 -0
- src/tools/__init__.py +25 -0
- src/tools/acls.py +328 -0
- src/tools/application.py +42 -0
- src/tools/backups.py +1173 -0
- src/tools/client_management.py +505 -0
- src/tools/clients.py +203 -0
- src/tools/device_control.py +325 -0
- src/tools/devices.py +354 -0
- src/tools/dpi.py +241 -0
- src/tools/dpi_tools.py +89 -0
- src/tools/firewall.py +417 -0
- src/tools/firewall_policies.py +430 -0
- src/tools/firewall_zones.py +515 -0
- src/tools/network_config.py +388 -0
- src/tools/networks.py +190 -0
- src/tools/port_forwarding.py +263 -0
- src/tools/qos.py +1070 -0
- src/tools/radius.py +763 -0
- src/tools/reference_data.py +107 -0
- src/tools/site_manager.py +466 -0
- src/tools/site_vpn.py +95 -0
- src/tools/sites.py +187 -0
- src/tools/topology.py +406 -0
- src/tools/traffic_flows.py +1062 -0
- src/tools/traffic_matching_lists.py +371 -0
- src/tools/vouchers.py +249 -0
- src/tools/vpn.py +76 -0
- src/tools/wans.py +30 -0
- src/tools/wifi.py +498 -0
- src/tools/zbf_matrix.py +326 -0
- src/utils/__init__.py +88 -0
- src/utils/audit.py +213 -0
- src/utils/exceptions.py +114 -0
- src/utils/helpers.py +159 -0
- src/utils/logger.py +105 -0
- src/utils/sanitize.py +244 -0
- src/utils/validators.py +160 -0
- src/webhooks/__init__.py +6 -0
- src/webhooks/handlers.py +196 -0
- 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")
|
src/models/zbf_matrix.py
ADDED
|
@@ -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"]
|
src/resources/clients.py
ADDED
|
@@ -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"
|
src/resources/devices.py
ADDED
|
@@ -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}"
|