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,371 @@
1
+ """Traffic Matching List management MCP tools."""
2
+
3
+ from typing import Any
4
+
5
+ from ..api import UniFiClient
6
+ from ..config import Settings
7
+ from ..models.traffic_matching_list import TrafficMatchingList, TrafficMatchingListCreate
8
+ from ..utils import (
9
+ ResourceNotFoundError,
10
+ ValidationError,
11
+ get_logger,
12
+ log_audit,
13
+ validate_confirmation,
14
+ validate_limit_offset,
15
+ validate_site_id,
16
+ )
17
+
18
+
19
+ async def list_traffic_matching_lists(
20
+ site_id: str,
21
+ settings: Settings,
22
+ limit: int | None = None,
23
+ offset: int | None = None,
24
+ ) -> list[dict[str, Any]]:
25
+ """List all traffic matching lists in a site (read-only).
26
+
27
+ Args:
28
+ site_id: Site identifier
29
+ settings: Application settings
30
+ limit: Maximum number of lists to return
31
+ offset: Number of lists to skip
32
+
33
+ Returns:
34
+ List of traffic matching list dictionaries
35
+ """
36
+ site_id = validate_site_id(site_id)
37
+ limit, offset = validate_limit_offset(limit, offset)
38
+ logger = get_logger(__name__, settings.log_level)
39
+
40
+ async with UniFiClient(settings) as client:
41
+ await client.authenticate()
42
+
43
+ response = await client.get(f"/integration/v1/sites/{site_id}/traffic-matching-lists")
44
+ lists_data: list[dict[str, Any]] = response.get("data", [])
45
+
46
+ # Apply pagination
47
+ paginated = lists_data[offset : offset + limit]
48
+
49
+ logger.info(f"Retrieved {len(paginated)} traffic matching lists for site '{site_id}'")
50
+ return [TrafficMatchingList(**lst).model_dump() for lst in paginated]
51
+
52
+
53
+ async def get_traffic_matching_list(
54
+ site_id: str,
55
+ list_id: str,
56
+ settings: Settings,
57
+ ) -> dict[str, Any]:
58
+ """Get details for a specific traffic matching list.
59
+
60
+ Args:
61
+ site_id: Site identifier
62
+ list_id: Traffic matching list ID
63
+ settings: Application settings
64
+
65
+ Returns:
66
+ Traffic matching list dictionary
67
+
68
+ Raises:
69
+ ResourceNotFoundError: If list not found
70
+ """
71
+ site_id = validate_site_id(site_id)
72
+ logger = get_logger(__name__, settings.log_level)
73
+
74
+ async with UniFiClient(settings) as client:
75
+ await client.authenticate()
76
+
77
+ response = await client.get(
78
+ f"/integration/v1/sites/{site_id}/traffic-matching-lists/{list_id}"
79
+ )
80
+
81
+ if isinstance(response, dict) and "data" in response:
82
+ list_data = response["data"]
83
+ else:
84
+ list_data = response
85
+
86
+ if not list_data:
87
+ raise ResourceNotFoundError("traffic_matching_list", list_id)
88
+
89
+ logger.info(f"Retrieved traffic matching list {list_id}")
90
+ return TrafficMatchingList(**list_data).model_dump() # type: ignore[no-any-return]
91
+
92
+
93
+ async def create_traffic_matching_list(
94
+ site_id: str,
95
+ list_type: str,
96
+ name: str,
97
+ items: list[str],
98
+ settings: Settings,
99
+ confirm: bool = False,
100
+ dry_run: bool = False,
101
+ ) -> dict[str, Any]:
102
+ """Create a new traffic matching list.
103
+
104
+ Args:
105
+ site_id: Site identifier
106
+ list_type: List type (PORTS, IPV4_ADDRESSES, IPV6_ADDRESSES)
107
+ name: List name
108
+ items: List items (ports, IPs, etc.)
109
+ settings: Application settings
110
+ confirm: Confirmation flag (must be True to execute)
111
+ dry_run: If True, validate but don't create
112
+
113
+ Returns:
114
+ Created list dictionary or dry-run result
115
+
116
+ Raises:
117
+ ConfirmationRequiredError: If confirm is not True
118
+ ValidationError: If validation fails
119
+ """
120
+ site_id = validate_site_id(site_id)
121
+ validate_confirmation(confirm, "traffic matching list operation")
122
+ logger = get_logger(__name__, settings.log_level)
123
+
124
+ # Validate list type
125
+ valid_types = ["PORTS", "IPV4_ADDRESSES", "IPV6_ADDRESSES"]
126
+ if list_type not in valid_types:
127
+ raise ValidationError(f"Invalid list type '{list_type}'. Must be one of: {valid_types}")
128
+
129
+ # Validate items not empty
130
+ if not items or len(items) == 0:
131
+ raise ValidationError("Items list cannot be empty")
132
+
133
+ # Build list data
134
+ create_data = TrafficMatchingListCreate(type=list_type, name=name, items=items)
135
+
136
+ parameters = {
137
+ "site_id": site_id,
138
+ "type": list_type,
139
+ "name": name,
140
+ "items_count": len(items),
141
+ }
142
+
143
+ if dry_run:
144
+ logger.info(f"DRY RUN: Would create traffic matching list '{name}' in site '{site_id}'")
145
+ log_audit(
146
+ operation="create_traffic_matching_list",
147
+ parameters=parameters,
148
+ result="dry_run",
149
+ site_id=site_id,
150
+ dry_run=True,
151
+ )
152
+ return {"dry_run": True, "would_create": create_data.model_dump()}
153
+
154
+ try:
155
+ async with UniFiClient(settings) as client:
156
+ await client.authenticate()
157
+
158
+ response = await client.post(
159
+ f"/integration/v1/sites/{site_id}/traffic-matching-lists",
160
+ json_data=create_data.model_dump(),
161
+ )
162
+ created_list: dict[str, Any] = response.get("data", response)
163
+
164
+ logger.info(f"Created traffic matching list '{name}' in site '{site_id}'")
165
+ log_audit(
166
+ operation="create_traffic_matching_list",
167
+ parameters=parameters,
168
+ result="success",
169
+ site_id=site_id,
170
+ )
171
+
172
+ return created_list
173
+
174
+ except Exception as e:
175
+ logger.error(f"Failed to create traffic matching list '{name}': {e}")
176
+ log_audit(
177
+ operation="create_traffic_matching_list",
178
+ parameters=parameters,
179
+ result="failed",
180
+ site_id=site_id,
181
+ )
182
+ raise
183
+
184
+
185
+ async def update_traffic_matching_list(
186
+ site_id: str,
187
+ list_id: str,
188
+ settings: Settings,
189
+ list_type: str | None = None,
190
+ name: str | None = None,
191
+ items: list[str] | None = None,
192
+ confirm: bool = False,
193
+ dry_run: bool = False,
194
+ ) -> dict[str, Any]:
195
+ """Update an existing traffic matching list.
196
+
197
+ Args:
198
+ site_id: Site identifier
199
+ list_id: Traffic matching list ID
200
+ settings: Application settings
201
+ list_type: New list type
202
+ name: New list name
203
+ items: New list items
204
+ confirm: Confirmation flag (must be True to execute)
205
+ dry_run: If True, validate but don't update
206
+
207
+ Returns:
208
+ Updated list dictionary or dry-run result
209
+
210
+ Raises:
211
+ ConfirmationRequiredError: If confirm is not True
212
+ ResourceNotFoundError: If list not found
213
+ """
214
+ site_id = validate_site_id(site_id)
215
+ validate_confirmation(confirm, "traffic matching list operation")
216
+ logger = get_logger(__name__, settings.log_level)
217
+
218
+ # Validate list type if provided
219
+ if list_type is not None:
220
+ valid_types = ["PORTS", "IPV4_ADDRESSES", "IPV6_ADDRESSES"]
221
+ if list_type not in valid_types:
222
+ raise ValidationError(f"Invalid list type '{list_type}'. Must be one of: {valid_types}")
223
+
224
+ # Validate items if provided
225
+ if items is not None and len(items) == 0:
226
+ raise ValidationError("Items list cannot be empty")
227
+
228
+ parameters = {
229
+ "site_id": site_id,
230
+ "list_id": list_id,
231
+ "type": list_type,
232
+ "name": name,
233
+ "items_count": len(items) if items else None,
234
+ }
235
+
236
+ if dry_run:
237
+ logger.info(f"DRY RUN: Would update traffic matching list '{list_id}' in site '{site_id}'")
238
+ log_audit(
239
+ operation="update_traffic_matching_list",
240
+ parameters=parameters,
241
+ result="dry_run",
242
+ site_id=site_id,
243
+ dry_run=True,
244
+ )
245
+ return {"dry_run": True, "would_update": parameters}
246
+
247
+ try:
248
+ async with UniFiClient(settings) as client:
249
+ await client.authenticate()
250
+
251
+ # Get existing list
252
+ response = await client.get(
253
+ f"/integration/v1/sites/{site_id}/traffic-matching-lists/{list_id}"
254
+ )
255
+ existing_list = response.get("data", response)
256
+
257
+ if not existing_list:
258
+ raise ResourceNotFoundError("traffic_matching_list", list_id)
259
+
260
+ # Build update data
261
+ update_data = existing_list.copy()
262
+
263
+ if list_type is not None:
264
+ update_data["type"] = list_type
265
+ if name is not None:
266
+ update_data["name"] = name
267
+ if items is not None:
268
+ update_data["items"] = items
269
+
270
+ response = await client.put(
271
+ f"/integration/v1/sites/{site_id}/traffic-matching-lists/{list_id}",
272
+ json_data=update_data,
273
+ )
274
+ updated_list: dict[str, Any] = response.get("data", response)
275
+
276
+ logger.info(f"Updated traffic matching list '{list_id}' in site '{site_id}'")
277
+ log_audit(
278
+ operation="update_traffic_matching_list",
279
+ parameters=parameters,
280
+ result="success",
281
+ site_id=site_id,
282
+ )
283
+
284
+ return updated_list
285
+
286
+ except Exception as e:
287
+ logger.error(f"Failed to update traffic matching list '{list_id}': {e}")
288
+ log_audit(
289
+ operation="update_traffic_matching_list",
290
+ parameters=parameters,
291
+ result="failed",
292
+ site_id=site_id,
293
+ )
294
+ raise
295
+
296
+
297
+ async def delete_traffic_matching_list(
298
+ site_id: str,
299
+ list_id: str,
300
+ settings: Settings,
301
+ confirm: bool = False,
302
+ dry_run: bool = False,
303
+ ) -> dict[str, Any]:
304
+ """Delete a traffic matching list.
305
+
306
+ Args:
307
+ site_id: Site identifier
308
+ list_id: Traffic matching list ID
309
+ settings: Application settings
310
+ confirm: Confirmation flag (must be True to execute)
311
+ dry_run: If True, validate but don't delete
312
+
313
+ Returns:
314
+ Deletion result dictionary
315
+
316
+ Raises:
317
+ ConfirmationRequiredError: If confirm is not True
318
+ ResourceNotFoundError: If list not found
319
+ """
320
+ site_id = validate_site_id(site_id)
321
+ validate_confirmation(confirm, "traffic matching list operation")
322
+ logger = get_logger(__name__, settings.log_level)
323
+
324
+ parameters = {"site_id": site_id, "list_id": list_id}
325
+
326
+ if dry_run:
327
+ logger.info(
328
+ f"DRY RUN: Would delete traffic matching list '{list_id}' from site '{site_id}'"
329
+ )
330
+ log_audit(
331
+ operation="delete_traffic_matching_list",
332
+ parameters=parameters,
333
+ result="dry_run",
334
+ site_id=site_id,
335
+ dry_run=True,
336
+ )
337
+ return {"dry_run": True, "would_delete": list_id}
338
+
339
+ try:
340
+ async with UniFiClient(settings) as client:
341
+ await client.authenticate()
342
+
343
+ # Verify list exists before deleting
344
+ try:
345
+ await client.get(
346
+ f"/integration/v1/sites/{site_id}/traffic-matching-lists/{list_id}"
347
+ )
348
+ except Exception as err:
349
+ raise ResourceNotFoundError("traffic_matching_list", list_id) from err
350
+
351
+ await client.delete(f"/integration/v1/sites/{site_id}/traffic-matching-lists/{list_id}")
352
+
353
+ logger.info(f"Deleted traffic matching list '{list_id}' from site '{site_id}'")
354
+ log_audit(
355
+ operation="delete_traffic_matching_list",
356
+ parameters=parameters,
357
+ result="success",
358
+ site_id=site_id,
359
+ )
360
+
361
+ return {"success": True, "deleted_list_id": list_id}
362
+
363
+ except Exception as e:
364
+ logger.error(f"Failed to delete traffic matching list '{list_id}': {e}")
365
+ log_audit(
366
+ operation="delete_traffic_matching_list",
367
+ parameters=parameters,
368
+ result="failed",
369
+ site_id=site_id,
370
+ )
371
+ raise
src/tools/vouchers.py ADDED
@@ -0,0 +1,249 @@
1
+ """Hotspot voucher management tools."""
2
+
3
+ from typing import Any
4
+
5
+ from ..api.client import UniFiClient
6
+ from ..config import Settings
7
+ from ..models import Voucher
8
+ from ..utils import audit_action, get_logger, validate_confirmation
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ async def list_vouchers(
14
+ site_id: str,
15
+ settings: Settings,
16
+ limit: int | None = None,
17
+ offset: int | None = None,
18
+ filter_expr: str | None = None,
19
+ ) -> list[dict]:
20
+ """List all hotspot vouchers for a site.
21
+
22
+ Args:
23
+ site_id: Site identifier
24
+ settings: Application settings
25
+ limit: Maximum number of results
26
+ offset: Starting position
27
+ filter_expr: Filter expression
28
+
29
+ Returns:
30
+ List of vouchers
31
+ """
32
+ async with UniFiClient(settings) as client:
33
+ logger.info(f"Listing vouchers for site {site_id}")
34
+
35
+ if not client.is_authenticated:
36
+ await client.authenticate()
37
+
38
+ params: dict[str, Any] = {}
39
+ if limit is not None:
40
+ params["limit"] = limit
41
+ if offset is not None:
42
+ params["offset"] = offset
43
+ if filter_expr:
44
+ params["filter"] = filter_expr
45
+
46
+ response = await client.get(f"/integration/v1/sites/{site_id}/vouchers", params=params)
47
+ data = response.get("data", [])
48
+
49
+ return [Voucher(**voucher).model_dump() for voucher in data]
50
+
51
+
52
+ async def get_voucher(site_id: str, voucher_id: str, settings: Settings) -> dict:
53
+ """Get details for a specific voucher.
54
+
55
+ Args:
56
+ site_id: Site identifier
57
+ voucher_id: Voucher identifier
58
+ settings: Application settings
59
+
60
+ Returns:
61
+ Voucher details
62
+ """
63
+ async with UniFiClient(settings) as client:
64
+ logger.info(f"Getting voucher {voucher_id} for site {site_id}")
65
+
66
+ if not client.is_authenticated:
67
+ await client.authenticate()
68
+
69
+ response = await client.get(f"/integration/v1/sites/{site_id}/vouchers/{voucher_id}")
70
+ data = response.get("data", response)
71
+
72
+ return Voucher(**data).model_dump() # type: ignore[no-any-return]
73
+
74
+
75
+ async def create_vouchers(
76
+ site_id: str,
77
+ count: int,
78
+ duration: int,
79
+ settings: Settings,
80
+ upload_limit_kbps: int | None = None,
81
+ download_limit_kbps: int | None = None,
82
+ upload_quota_mb: int | None = None,
83
+ download_quota_mb: int | None = None,
84
+ note: str | None = None,
85
+ confirm: bool = False,
86
+ dry_run: bool = False,
87
+ ) -> dict:
88
+ """Create new hotspot vouchers.
89
+
90
+ Args:
91
+ site_id: Site identifier
92
+ count: Number of vouchers to create
93
+ duration: Duration in seconds
94
+ settings: Application settings
95
+ upload_limit_kbps: Upload speed limit in kbps
96
+ download_limit_kbps: Download speed limit in kbps
97
+ upload_quota_mb: Upload quota in MB
98
+ download_quota_mb: Download quota in MB
99
+ note: Admin notes
100
+ confirm: Confirmation flag (required)
101
+ dry_run: If True, validate but don't execute
102
+
103
+ Returns:
104
+ Created voucher codes
105
+ """
106
+ validate_confirmation(confirm, "create vouchers")
107
+
108
+ async with UniFiClient(settings) as client:
109
+ logger.info(f"Creating {count} vouchers for site {site_id}")
110
+
111
+ if not client.is_authenticated:
112
+ await client.authenticate()
113
+
114
+ # Build request payload
115
+ payload: dict[str, Any] = {
116
+ "count": count,
117
+ "duration": duration,
118
+ }
119
+
120
+ if upload_limit_kbps is not None:
121
+ payload["uploadLimit"] = upload_limit_kbps
122
+ if download_limit_kbps is not None:
123
+ payload["downloadLimit"] = download_limit_kbps
124
+ if upload_quota_mb is not None:
125
+ payload["uploadQuota"] = upload_quota_mb
126
+ if download_quota_mb is not None:
127
+ payload["downloadQuota"] = download_quota_mb
128
+ if note:
129
+ payload["note"] = note
130
+
131
+ if dry_run:
132
+ logger.info(f"[DRY RUN] Would create vouchers with payload: {payload}")
133
+ return {"dry_run": True, "payload": payload}
134
+
135
+ response = await client.post(f"/integration/v1/sites/{site_id}/vouchers", json_data=payload)
136
+ data = response.get("data", response)
137
+
138
+ # Audit the action
139
+ await audit_action(
140
+ settings,
141
+ action_type="create_vouchers",
142
+ resource_type="voucher",
143
+ resource_id="bulk",
144
+ site_id=site_id,
145
+ details={"count": count, "duration": duration},
146
+ )
147
+
148
+ return {
149
+ "success": True,
150
+ "count": count,
151
+ "vouchers": data if isinstance(data, list) else [data],
152
+ }
153
+
154
+
155
+ async def delete_voucher(
156
+ site_id: str,
157
+ voucher_id: str,
158
+ settings: Settings,
159
+ confirm: bool = False,
160
+ dry_run: bool = False,
161
+ ) -> dict:
162
+ """Delete a specific voucher.
163
+
164
+ Args:
165
+ site_id: Site identifier
166
+ voucher_id: Voucher identifier
167
+ settings: Application settings
168
+ confirm: Confirmation flag (required)
169
+ dry_run: If True, validate but don't execute
170
+
171
+ Returns:
172
+ Deletion status
173
+ """
174
+ validate_confirmation(confirm, "delete voucher")
175
+
176
+ async with UniFiClient(settings) as client:
177
+ logger.info(f"Deleting voucher {voucher_id} for site {site_id}")
178
+
179
+ if not client.is_authenticated:
180
+ await client.authenticate()
181
+
182
+ if dry_run:
183
+ logger.info(f"[DRY RUN] Would delete voucher {voucher_id}")
184
+ return {"dry_run": True, "voucher_id": voucher_id}
185
+
186
+ await client.delete(f"/integration/v1/sites/{site_id}/vouchers/{voucher_id}")
187
+
188
+ # Audit the action
189
+ await audit_action(
190
+ settings,
191
+ action_type="delete_voucher",
192
+ resource_type="voucher",
193
+ resource_id=voucher_id,
194
+ site_id=site_id,
195
+ details={},
196
+ )
197
+
198
+ return {"success": True, "message": f"Voucher {voucher_id} deleted successfully"}
199
+
200
+
201
+ async def bulk_delete_vouchers(
202
+ site_id: str,
203
+ filter_expr: str,
204
+ settings: Settings,
205
+ confirm: bool = False,
206
+ dry_run: bool = False,
207
+ ) -> dict:
208
+ """Bulk delete vouchers using a filter expression.
209
+
210
+ Args:
211
+ site_id: Site identifier
212
+ filter_expr: Filter expression to select vouchers
213
+ settings: Application settings
214
+ confirm: Confirmation flag (required)
215
+ dry_run: If True, validate but don't execute
216
+
217
+ Returns:
218
+ Deletion status
219
+ """
220
+ validate_confirmation(confirm, "bulk delete vouchers")
221
+
222
+ async with UniFiClient(settings) as client:
223
+ logger.info(f"Bulk deleting vouchers for site {site_id} with filter: {filter_expr}")
224
+
225
+ if not client.is_authenticated:
226
+ await client.authenticate()
227
+
228
+ if dry_run:
229
+ logger.info(f"[DRY RUN] Would bulk delete vouchers with filter: {filter_expr}")
230
+ return {"dry_run": True, "filter": filter_expr}
231
+
232
+ params = {"filter": filter_expr}
233
+ response = await client.delete(f"/integration/v1/sites/{site_id}/vouchers", params=params)
234
+
235
+ # Audit the action
236
+ await audit_action(
237
+ settings,
238
+ action_type="bulk_delete_vouchers",
239
+ resource_type="voucher",
240
+ resource_id="bulk",
241
+ site_id=site_id,
242
+ details={"filter": filter_expr},
243
+ )
244
+
245
+ return {
246
+ "success": True,
247
+ "message": "Vouchers deleted successfully",
248
+ "deleted_count": response.get("data", {}).get("count", 0),
249
+ }
src/tools/vpn.py ADDED
@@ -0,0 +1,76 @@
1
+ """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 VPNServer, VPNTunnel
8
+ from ..utils import get_logger, validate_limit_offset, validate_site_id
9
+
10
+
11
+ async def list_vpn_tunnels(
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 site-to-site VPN tunnels in a site (read-only).
18
+
19
+ Args:
20
+ site_id: Site identifier
21
+ settings: Application settings
22
+ limit: Maximum number of tunnels to return
23
+ offset: Number of tunnels to skip
24
+
25
+ Returns:
26
+ List of VPN tunnel 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}/vpn/site-to-site-tunnels")
36
+ tunnels_data: list[dict[str, Any]] = response.get("data", [])
37
+
38
+ # Apply pagination
39
+ paginated = tunnels_data[offset : offset + limit]
40
+
41
+ logger.info(f"Retrieved {len(paginated)} VPN tunnels for site '{site_id}'")
42
+ return [VPNTunnel(**tunnel).model_dump() for tunnel in paginated]
43
+
44
+
45
+ async def list_vpn_servers(
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 VPN servers in a site (read-only).
52
+
53
+ Args:
54
+ site_id: Site identifier
55
+ settings: Application settings
56
+ limit: Maximum number of servers to return
57
+ offset: Number of servers to skip
58
+
59
+ Returns:
60
+ List of VPN server 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}/vpn/servers")
70
+ servers_data: list[dict[str, Any]] = response.get("data", [])
71
+
72
+ # Apply pagination
73
+ paginated = servers_data[offset : offset + limit]
74
+
75
+ logger.info(f"Retrieved {len(paginated)} VPN servers for site '{site_id}'")
76
+ return [VPNServer(**server).model_dump() for server in paginated]