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,107 @@
1
+ """Reference data MCP tools for supporting resources."""
2
+
3
+ from typing import Any
4
+
5
+ from ..api import UniFiClient
6
+ from ..config import Settings
7
+ from ..models.reference_data import Country, DeviceTag
8
+ from ..utils import get_logger, validate_limit_offset, validate_site_id
9
+
10
+
11
+ async def list_radius_profiles(
12
+ site_id: str,
13
+ settings: Settings,
14
+ limit: int | None = None,
15
+ offset: int | None = None,
16
+ ) -> list[dict[str, Any]]:
17
+ """List all RADIUS profiles in a site (read-only).
18
+
19
+ Args:
20
+ site_id: Site identifier
21
+ settings: Application settings
22
+ limit: Maximum number of profiles to return
23
+ offset: Number of profiles to skip
24
+
25
+ Returns:
26
+ List of RADIUS profile dictionaries
27
+ """
28
+ site_id = validate_site_id(site_id)
29
+ limit, offset = validate_limit_offset(limit, offset)
30
+ logger = get_logger(__name__, settings.log_level)
31
+
32
+ async with UniFiClient(settings) as client:
33
+ await client.authenticate()
34
+
35
+ response = await client.get(f"/integration/v1/sites/{site_id}/radius/profiles")
36
+ profiles_data: list[dict[str, Any]] = response.get("data", [])
37
+
38
+ # Apply pagination
39
+ paginated = profiles_data[offset : offset + limit]
40
+
41
+ logger.info(f"Retrieved {len(paginated)} RADIUS profiles for site '{site_id}'")
42
+ return paginated
43
+
44
+
45
+ async def list_device_tags(
46
+ site_id: str,
47
+ settings: Settings,
48
+ limit: int | None = None,
49
+ offset: int | None = None,
50
+ ) -> list[dict[str, Any]]:
51
+ """List all device tags in a site (read-only).
52
+
53
+ Args:
54
+ site_id: Site identifier
55
+ settings: Application settings
56
+ limit: Maximum number of tags to return
57
+ offset: Number of tags to skip
58
+
59
+ Returns:
60
+ List of device tag dictionaries
61
+ """
62
+ site_id = validate_site_id(site_id)
63
+ limit, offset = validate_limit_offset(limit, offset)
64
+ logger = get_logger(__name__, settings.log_level)
65
+
66
+ async with UniFiClient(settings) as client:
67
+ await client.authenticate()
68
+
69
+ response = await client.get(f"/integration/v1/sites/{site_id}/device-tags")
70
+ tags_data: list[dict[str, Any]] = response.get("data", [])
71
+
72
+ # Apply pagination
73
+ paginated = tags_data[offset : offset + limit]
74
+
75
+ logger.info(f"Retrieved {len(paginated)} device tags for site '{site_id}'")
76
+ return [DeviceTag(**tag).model_dump() for tag in paginated]
77
+
78
+
79
+ async def list_countries(
80
+ settings: Settings,
81
+ limit: int | None = None,
82
+ offset: int | None = None,
83
+ ) -> list[dict[str, Any]]:
84
+ """List all countries with ISO codes (read-only).
85
+
86
+ Args:
87
+ settings: Application settings
88
+ limit: Maximum number of countries to return
89
+ offset: Number of countries to skip
90
+
91
+ Returns:
92
+ List of country dictionaries
93
+ """
94
+ limit, offset = validate_limit_offset(limit, offset)
95
+ logger = get_logger(__name__, settings.log_level)
96
+
97
+ async with UniFiClient(settings) as client:
98
+ await client.authenticate()
99
+
100
+ response = await client.get("/integration/v1/countries")
101
+ countries_data: list[dict[str, Any]] = response.get("data", [])
102
+
103
+ # Apply pagination
104
+ paginated = countries_data[offset : offset + limit]
105
+
106
+ logger.info(f"Retrieved {len(paginated)} countries")
107
+ return [Country(**country).model_dump() for country in paginated]
@@ -0,0 +1,466 @@
1
+ """Site Manager API tools for multi-site management."""
2
+
3
+ from typing import Any
4
+
5
+ from ..api.site_manager_client import SiteManagerClient
6
+ from ..config import Settings
7
+ from ..models.site_manager import (
8
+ CrossSitePerformanceComparison,
9
+ CrossSiteSearchResult,
10
+ CrossSiteStatistics,
11
+ InternetHealthMetrics,
12
+ SiteHealthSummary,
13
+ SiteInventory,
14
+ SitePerformanceMetrics,
15
+ VantagePoint,
16
+ )
17
+ from ..utils import get_logger
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ async def list_all_sites_aggregated(settings: Settings) -> list[dict[str, Any]]:
23
+ """List all sites with aggregated stats from Site Manager API.
24
+
25
+ Args:
26
+ settings: Application settings
27
+
28
+ Returns:
29
+ List of sites with aggregated statistics
30
+ """
31
+ if not settings.site_manager_enabled:
32
+ raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
33
+
34
+ async with SiteManagerClient(settings) as client:
35
+ logger.info("Retrieving aggregated site list from Site Manager API")
36
+
37
+ response = await client.list_sites()
38
+ sites_data = response.get("data", response.get("sites", []))
39
+
40
+ # Enhance with aggregated stats if available
41
+ sites: list[dict[str, Any]] = []
42
+ for site in sites_data:
43
+ sites.append(site)
44
+
45
+ return sites
46
+
47
+
48
+ async def get_internet_health(settings: Settings, site_id: str | None = None) -> dict[str, Any]:
49
+ """Get internet health metrics across sites.
50
+
51
+ Args:
52
+ settings: Application settings
53
+ site_id: Optional site identifier. If None, returns aggregate metrics.
54
+
55
+ Returns:
56
+ Internet health metrics
57
+ """
58
+ if not settings.site_manager_enabled:
59
+ raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
60
+
61
+ async with SiteManagerClient(settings) as client:
62
+ logger.info(f"Retrieving internet health metrics (site_id={site_id})")
63
+
64
+ response = await client.get_internet_health(site_id)
65
+ data = response.get("data", response)
66
+
67
+ return InternetHealthMetrics(**data).model_dump() # type: ignore[no-any-return]
68
+
69
+
70
+ async def get_site_health_summary(
71
+ settings: Settings, site_id: str | None = None
72
+ ) -> dict[str, Any] | list[dict[str, Any]]:
73
+ """Get health summary for all sites or a specific site.
74
+
75
+ Args:
76
+ settings: Application settings
77
+ site_id: Optional site identifier. If None, returns summary for all sites.
78
+
79
+ Returns:
80
+ Health summary
81
+ """
82
+ if not settings.site_manager_enabled:
83
+ raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
84
+
85
+ async with SiteManagerClient(settings) as client:
86
+ logger.info(f"Retrieving site health summary (site_id={site_id})")
87
+
88
+ response = await client.get_site_health(site_id)
89
+ # Client now auto-unwraps the "data" field, so response is the actual data
90
+ data = response
91
+
92
+ if site_id:
93
+ return SiteHealthSummary(**data).model_dump() # type: ignore[no-any-return]
94
+ else:
95
+ # Multiple sites - response is already a list or dict with sites
96
+ summaries = data.get("sites", []) if isinstance(data, dict) else data
97
+ return [SiteHealthSummary(**summary).model_dump() for summary in summaries]
98
+
99
+
100
+ async def get_cross_site_statistics(settings: Settings) -> dict[str, Any]:
101
+ """Get aggregate statistics across multiple sites.
102
+
103
+ Args:
104
+ settings: Application settings
105
+
106
+ Returns:
107
+ Cross-site statistics
108
+ """
109
+ if not settings.site_manager_enabled:
110
+ raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
111
+
112
+ async with SiteManagerClient(settings) as client:
113
+ logger.info("Retrieving cross-site statistics")
114
+
115
+ # Get all sites with health
116
+ sites_response = await client.list_sites()
117
+ sites_data = sites_response.get("data", sites_response.get("sites", []))
118
+
119
+ health_response = await client.get_site_health()
120
+ health_data = health_response.get("data", health_response)
121
+
122
+ # Aggregate statistics
123
+ total_sites = len(sites_data)
124
+ sites_healthy = 0
125
+ sites_degraded = 0
126
+ sites_down = 0
127
+ total_devices = 0
128
+ devices_online = 0
129
+ total_clients = 0
130
+ total_bandwidth_up_mbps = 0.0
131
+ total_bandwidth_down_mbps = 0.0
132
+
133
+ site_summaries: list[SiteHealthSummary] = []
134
+ if isinstance(health_data, list):
135
+ for health in health_data:
136
+ status = health.get("status", "unknown")
137
+ if status == "healthy":
138
+ sites_healthy += 1
139
+ elif status == "degraded":
140
+ sites_degraded += 1
141
+ elif status == "down":
142
+ sites_down += 1
143
+
144
+ site_summaries.append(SiteHealthSummary(**health))
145
+ total_devices += health.get("devices_total", 0)
146
+ devices_online += health.get("devices_online", 0)
147
+ total_clients += health.get("clients_active", 0)
148
+
149
+ return CrossSiteStatistics( # type: ignore[no-any-return]
150
+ total_sites=total_sites,
151
+ sites_healthy=sites_healthy,
152
+ sites_degraded=sites_degraded,
153
+ sites_down=sites_down,
154
+ total_devices=total_devices,
155
+ devices_online=devices_online,
156
+ total_clients=total_clients,
157
+ total_bandwidth_up_mbps=total_bandwidth_up_mbps,
158
+ total_bandwidth_down_mbps=total_bandwidth_down_mbps,
159
+ site_summaries=site_summaries,
160
+ ).model_dump()
161
+
162
+
163
+ async def list_vantage_points(settings: Settings) -> list[dict[str, Any]]:
164
+ """List all Vantage Points.
165
+
166
+ Args:
167
+ settings: Application settings
168
+
169
+ Returns:
170
+ List of Vantage Points
171
+ """
172
+ if not settings.site_manager_enabled:
173
+ raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
174
+
175
+ async with SiteManagerClient(settings) as client:
176
+ logger.info("Retrieving Vantage Points")
177
+
178
+ response = await client.list_vantage_points()
179
+ # Client now auto-unwraps the "data" field, so response is the actual data
180
+ data = response.get("vantage_points", []) if isinstance(response, dict) else response
181
+
182
+ return [VantagePoint(**vp).model_dump() for vp in data]
183
+
184
+
185
+ async def get_site_inventory(
186
+ settings: Settings, site_id: str | None = None
187
+ ) -> dict[str, Any] | list[dict[str, Any]]:
188
+ """Get comprehensive inventory for a site or all sites.
189
+
190
+ Provides detailed breakdown of resources including devices, clients,
191
+ networks, SSIDs, VPN tunnels, and firewall rules.
192
+
193
+ Args:
194
+ settings: Application settings
195
+ site_id: Optional site identifier. If None, returns inventory for all sites.
196
+
197
+ Returns:
198
+ Site inventory or list of site inventories
199
+ """
200
+ if not settings.site_manager_enabled:
201
+ raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
202
+
203
+ async with SiteManagerClient(settings) as client:
204
+ logger.info(f"Retrieving site inventory (site_id={site_id})")
205
+
206
+ if site_id:
207
+ # Get inventory for specific site
208
+ site_response = await client.get(f"sites/{site_id}")
209
+ site_data = site_response.get("data", site_response)
210
+
211
+ # Fetch detailed counts (these would come from various endpoints)
212
+ # For now, using available data from site response
213
+ inventory = SiteInventory(
214
+ site_id=site_id,
215
+ site_name=site_data.get("name", site_id),
216
+ device_count=site_data.get("device_count", 0),
217
+ device_types=site_data.get("device_types", {}),
218
+ client_count=site_data.get("client_count", 0),
219
+ network_count=site_data.get("network_count", 0),
220
+ ssid_count=site_data.get("ssid_count", 0),
221
+ uplink_count=site_data.get("uplink_count", 0),
222
+ vpn_tunnel_count=site_data.get("vpn_tunnel_count", 0),
223
+ firewall_rule_count=site_data.get("firewall_rule_count", 0),
224
+ last_updated=site_data.get("last_updated", ""),
225
+ )
226
+ return inventory.model_dump() # type: ignore[no-any-return]
227
+ else:
228
+ # Get inventory for all sites
229
+ sites_response = await client.list_sites()
230
+ sites_data = sites_response.get("data", sites_response.get("sites", []))
231
+
232
+ inventories = []
233
+ for site in sites_data:
234
+ inventory = SiteInventory(
235
+ site_id=site.get("site_id", ""),
236
+ site_name=site.get("name", ""),
237
+ device_count=site.get("device_count", 0),
238
+ device_types=site.get("device_types", {}),
239
+ client_count=site.get("client_count", 0),
240
+ network_count=site.get("network_count", 0),
241
+ ssid_count=site.get("ssid_count", 0),
242
+ uplink_count=site.get("uplink_count", 0),
243
+ vpn_tunnel_count=site.get("vpn_tunnel_count", 0),
244
+ firewall_rule_count=site.get("firewall_rule_count", 0),
245
+ last_updated=site.get("last_updated", ""),
246
+ )
247
+ inventories.append(inventory.model_dump())
248
+
249
+ return inventories
250
+
251
+
252
+ async def compare_site_performance(settings: Settings) -> dict[str, Any]:
253
+ """Compare performance metrics across all sites.
254
+
255
+ Analyzes uptime, latency, bandwidth, and health status to identify
256
+ best and worst performing sites.
257
+
258
+ Args:
259
+ settings: Application settings
260
+
261
+ Returns:
262
+ Performance comparison with rankings and metrics
263
+ """
264
+ if not settings.site_manager_enabled:
265
+ raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
266
+
267
+ async with SiteManagerClient(settings) as client:
268
+ logger.info("Comparing performance across sites")
269
+
270
+ # Get site health data
271
+ health_response = await client.get_site_health()
272
+ health_data = health_response.get("data", health_response)
273
+
274
+ # Get internet health data for bandwidth/latency
275
+ internet_response = await client.get_internet_health()
276
+ internet_data = internet_response.get("data", internet_response)
277
+
278
+ site_metrics: list[SitePerformanceMetrics] = []
279
+
280
+ # Process health data
281
+ if isinstance(health_data, list):
282
+ for health in health_data:
283
+ site_id = health.get("site_id", "")
284
+
285
+ # Calculate device online percentage
286
+ devices_total = health.get("devices_total", 0)
287
+ devices_online = health.get("devices_online", 0)
288
+ device_online_pct = (
289
+ (devices_online / devices_total * 100) if devices_total > 0 else 0.0
290
+ )
291
+
292
+ # Find matching internet health data
293
+ internet_health = None
294
+ if isinstance(internet_data, list):
295
+ internet_health = next(
296
+ (i for i in internet_data if i.get("site_id") == site_id), None
297
+ )
298
+ elif isinstance(internet_data, dict) and internet_data.get("site_id") == site_id:
299
+ internet_health = internet_data
300
+
301
+ metrics = SitePerformanceMetrics(
302
+ site_id=site_id,
303
+ site_name=health.get("site_name", site_id),
304
+ avg_latency_ms=internet_health.get("latency_ms") if internet_health else None,
305
+ avg_bandwidth_up_mbps=(
306
+ internet_health.get("bandwidth_up_mbps") if internet_health else None
307
+ ),
308
+ avg_bandwidth_down_mbps=(
309
+ internet_health.get("bandwidth_down_mbps") if internet_health else None
310
+ ),
311
+ uptime_percentage=health.get("uptime_percentage", 0.0),
312
+ device_online_percentage=device_online_pct,
313
+ client_count=health.get("clients_active", 0),
314
+ health_status=health.get("status", "down"),
315
+ )
316
+ site_metrics.append(metrics)
317
+
318
+ # Calculate best and worst performers
319
+ # Best = highest uptime and device online percentage
320
+ best_site = None
321
+ worst_site = None
322
+
323
+ if site_metrics:
324
+ # Sort by uptime (primary) and device online percentage (secondary)
325
+ sorted_sites = sorted(
326
+ site_metrics,
327
+ key=lambda s: (s.uptime_percentage, s.device_online_percentage),
328
+ reverse=True,
329
+ )
330
+ best_site = sorted_sites[0] if sorted_sites else None
331
+ worst_site = sorted_sites[-1] if sorted_sites else None
332
+
333
+ # Calculate average uptime
334
+ avg_uptime = (
335
+ sum(m.uptime_percentage for m in site_metrics) / len(site_metrics)
336
+ if site_metrics
337
+ else 0.0
338
+ )
339
+
340
+ # Calculate average latency (excluding None values)
341
+ latencies = [m.avg_latency_ms for m in site_metrics if m.avg_latency_ms is not None]
342
+ avg_latency = sum(latencies) / len(latencies) if latencies else None
343
+
344
+ comparison = CrossSitePerformanceComparison(
345
+ total_sites=len(site_metrics),
346
+ best_performing_site=best_site,
347
+ worst_performing_site=worst_site,
348
+ average_uptime=avg_uptime,
349
+ average_latency_ms=avg_latency,
350
+ site_metrics=site_metrics,
351
+ )
352
+
353
+ return comparison.model_dump() # type: ignore[no-any-return]
354
+
355
+
356
+ async def search_across_sites(
357
+ settings: Settings,
358
+ query: str,
359
+ search_type: str = "all",
360
+ ) -> dict[str, Any]:
361
+ """Search for resources across all sites.
362
+
363
+ Search for devices, clients, or networks across all managed sites.
364
+ Useful for locating resources in multi-site deployments.
365
+
366
+ Args:
367
+ settings: Application settings
368
+ query: Search query (device name, MAC address, client name, network name)
369
+ search_type: Type of search - "device", "client", "network", or "all"
370
+
371
+ Returns:
372
+ Search results with site context
373
+ """
374
+ if not settings.site_manager_enabled:
375
+ raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
376
+
377
+ valid_types = ["device", "client", "network", "all"]
378
+ if search_type not in valid_types:
379
+ raise ValueError(f"search_type must be one of {valid_types}, got '{search_type}'")
380
+
381
+ async with SiteManagerClient(settings) as client:
382
+ logger.info(f"Searching across sites: query='{query}', type={search_type}")
383
+
384
+ # Get all sites first
385
+ sites_response = await client.list_sites()
386
+ sites_data = sites_response.get("data", sites_response.get("sites", []))
387
+
388
+ results: list[dict[str, Any]] = []
389
+ query_lower = query.lower()
390
+
391
+ # Search across each site
392
+ for site in sites_data:
393
+ site_id = site.get("site_id", "")
394
+ site_name = site.get("name", site_id)
395
+
396
+ # Search devices
397
+ if search_type in ["device", "all"]:
398
+ try:
399
+ # This would query the devices endpoint for each site
400
+ # For now, checking if site data includes device information
401
+ devices = site.get("devices", [])
402
+ for device in devices:
403
+ device_name = device.get("name", "").lower()
404
+ device_mac = device.get("mac", "").lower()
405
+ if query_lower in device_name or query_lower in device_mac:
406
+ results.append(
407
+ {
408
+ "type": "device",
409
+ "site_id": site_id,
410
+ "site_name": site_name,
411
+ "resource": device,
412
+ }
413
+ )
414
+ except Exception as e:
415
+ logger.debug(f"Error searching devices in site {site_id}: {e}")
416
+
417
+ # Search clients
418
+ if search_type in ["client", "all"]:
419
+ try:
420
+ clients = site.get("clients", [])
421
+ for client_obj in clients:
422
+ client_name = client_obj.get("name", "").lower()
423
+ client_mac = client_obj.get("mac", "").lower()
424
+ client_ip = client_obj.get("ip", "").lower()
425
+ if (
426
+ query_lower in client_name
427
+ or query_lower in client_mac
428
+ or query_lower in client_ip
429
+ ):
430
+ results.append(
431
+ {
432
+ "type": "client",
433
+ "site_id": site_id,
434
+ "site_name": site_name,
435
+ "resource": client_obj,
436
+ }
437
+ )
438
+ except Exception as e:
439
+ logger.debug(f"Error searching clients in site {site_id}: {e}")
440
+
441
+ # Search networks
442
+ if search_type in ["network", "all"]:
443
+ try:
444
+ networks = site.get("networks", [])
445
+ for network in networks:
446
+ network_name = network.get("name", "").lower()
447
+ if query_lower in network_name:
448
+ results.append(
449
+ {
450
+ "type": "network",
451
+ "site_id": site_id,
452
+ "site_name": site_name,
453
+ "resource": network,
454
+ }
455
+ )
456
+ except Exception as e:
457
+ logger.debug(f"Error searching networks in site {site_id}: {e}")
458
+
459
+ search_result = CrossSiteSearchResult(
460
+ total_results=len(results),
461
+ search_query=query,
462
+ result_type=search_type, # type: ignore[arg-type]
463
+ results=results,
464
+ )
465
+
466
+ return search_result.model_dump() # type: ignore[no-any-return]
src/tools/site_vpn.py ADDED
@@ -0,0 +1,95 @@
1
+ """Site-to-Site VPN management MCP tools."""
2
+
3
+ from typing import Any
4
+
5
+ from ..api import UniFiClient
6
+ from ..config import Settings
7
+ from ..models.vpn import SiteToSiteVPN
8
+ from ..utils import ResourceNotFoundError, get_logger, validate_site_id
9
+
10
+
11
+ async def list_site_to_site_vpns(site_id: str, settings: Settings) -> list[dict[str, Any]]:
12
+ """List all site-to-site VPN configurations."""
13
+ site_id = validate_site_id(site_id)
14
+ logger = get_logger(__name__, settings.log_level)
15
+
16
+ async with UniFiClient(settings) as client:
17
+ await client.authenticate()
18
+ response = await client.get(f"/proxy/network/api/s/{site_id}/rest/networkconf")
19
+ networks = response if isinstance(response, list) else response.get("data", [])
20
+ vpns = [n for n in networks if n.get("purpose") == "site-vpn"]
21
+ logger.info(f"Retrieved {len(vpns)} site-to-site VPNs")
22
+ return [SiteToSiteVPN(**v).model_dump() for v in vpns]
23
+
24
+
25
+ async def get_site_to_site_vpn(site_id: str, vpn_id: str, settings: Settings) -> dict[str, Any]:
26
+ """Get details for a specific site-to-site VPN."""
27
+ site_id = validate_site_id(site_id)
28
+ logger = get_logger(__name__, settings.log_level)
29
+
30
+ async with UniFiClient(settings) as client:
31
+ await client.authenticate()
32
+ response = await client.get(f"/proxy/network/api/s/{site_id}/rest/networkconf")
33
+ networks = response if isinstance(response, list) else response.get("data", [])
34
+
35
+ for n in networks:
36
+ if n.get("_id") == vpn_id and n.get("purpose") == "site-vpn":
37
+ logger.info(f"Retrieved VPN {vpn_id}")
38
+ return SiteToSiteVPN(**n).model_dump()
39
+
40
+ raise ResourceNotFoundError("vpn", vpn_id)
41
+
42
+
43
+ async def update_site_to_site_vpn(
44
+ site_id: str,
45
+ vpn_id: str,
46
+ settings: Settings,
47
+ *,
48
+ name: str | None = None,
49
+ enabled: bool | None = None,
50
+ ipsec_peer_ip: str | None = None,
51
+ remote_vpn_subnets: list[str] | None = None,
52
+ x_ipsec_pre_shared_key: str | None = None,
53
+ confirm: bool = False,
54
+ dry_run: bool = False,
55
+ ) -> dict[str, Any]:
56
+ """Update a site-to-site VPN configuration (requires confirm=True)."""
57
+ site_id = validate_site_id(site_id)
58
+ logger = get_logger(__name__, settings.log_level)
59
+
60
+ async with UniFiClient(settings) as client:
61
+ await client.authenticate()
62
+
63
+ # Get current config
64
+ response = await client.get(f"/proxy/network/api/s/{site_id}/rest/networkconf/{vpn_id}")
65
+ current = response if isinstance(response, dict) and "_id" in response else None
66
+ if not current:
67
+ resp_list = response if isinstance(response, list) else response.get("data", [])
68
+ current = resp_list[0] if resp_list else None
69
+ if not current or current.get("purpose") != "site-vpn":
70
+ raise ResourceNotFoundError("vpn", vpn_id)
71
+
72
+ # Build update payload
73
+ updates = {}
74
+ if name is not None:
75
+ updates["name"] = name
76
+ if enabled is not None:
77
+ updates["enabled"] = enabled
78
+ if ipsec_peer_ip is not None:
79
+ updates["ipsec_peer_ip"] = ipsec_peer_ip
80
+ if remote_vpn_subnets is not None:
81
+ updates["remote_vpn_subnets"] = remote_vpn_subnets
82
+ if x_ipsec_pre_shared_key is not None:
83
+ updates["x_ipsec_pre_shared_key"] = x_ipsec_pre_shared_key
84
+
85
+ if dry_run:
86
+ return {"dry_run": True, "vpn_id": vpn_id, "updates": updates}
87
+
88
+ if not confirm:
89
+ return {"error": "confirm=True required", "vpn_id": vpn_id, "updates": updates}
90
+
91
+ # Merge and update
92
+ payload = {**current, **updates}
93
+ await client.put(f"/proxy/network/api/s/{site_id}/rest/networkconf/{vpn_id}", payload)
94
+ logger.info(f"Updated VPN {vpn_id}")
95
+ return {"success": True, "vpn_id": vpn_id, "updates": updates}