unifi-network-mcp 0.3.1__tar.gz → 0.3.3__tar.gz

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 (52) hide show
  1. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/.gitignore +1 -0
  2. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/PKG-INFO +2 -2
  3. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/pyproject.toml +12 -3
  4. unifi_network_mcp-0.3.3/src/_version.py +34 -0
  5. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/connection_manager.py +116 -39
  6. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/network.py +12 -1
  7. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools_manifest.json +1 -1
  8. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/utils/lazy_tool_loader.py +86 -2
  9. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/.well-known/mcp-server.json +0 -0
  10. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/LICENSE +0 -0
  11. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/README.md +0 -0
  12. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/bootstrap.py +0 -0
  13. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/config/config.yaml +0 -0
  14. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/jobs.py +0 -0
  15. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/main.py +0 -0
  16. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/client_manager.py +0 -0
  17. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/device_manager.py +0 -0
  18. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/event_manager.py +0 -0
  19. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/firewall_manager.py +0 -0
  20. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/hotspot_manager.py +0 -0
  21. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/network_manager.py +0 -0
  22. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/qos_manager.py +0 -0
  23. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/routing_manager.py +0 -0
  24. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/stats_manager.py +0 -0
  25. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/system_manager.py +0 -0
  26. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/traffic_route_manager.py +0 -0
  27. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/usergroup_manager.py +0 -0
  28. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/vpn_manager.py +0 -0
  29. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/runtime.py +0 -0
  30. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/schemas.py +0 -0
  31. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tool_index.py +0 -0
  32. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/clients.py +0 -0
  33. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/config.py +0 -0
  34. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/devices.py +0 -0
  35. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/events.py +0 -0
  36. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/firewall.py +0 -0
  37. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/hotspot.py +0 -0
  38. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/port_forwards.py +0 -0
  39. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/qos.py +0 -0
  40. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/routing.py +0 -0
  41. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/stats.py +0 -0
  42. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/system.py +0 -0
  43. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/traffic_routes.py +0 -0
  44. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/usergroups.py +0 -0
  45. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/vpn.py +0 -0
  46. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/utils/confirmation.py +0 -0
  47. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/utils/diagnostics.py +0 -0
  48. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/utils/meta_tools.py +0 -0
  49. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/utils/permissions.py +0 -0
  50. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/utils/tool_loader.py +0 -0
  51. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/validator_registry.py +0 -0
  52. {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/validators.py +0 -0
@@ -3,6 +3,7 @@
3
3
 
4
4
  # Python
5
5
  __pycache__/
6
+ src/_version.py
6
7
  *.py[cod]
7
8
  *$py.class
8
9
  *.so
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-network-mcp
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Unifi Network MCP Server
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.13
7
7
  Requires-Dist: aiohttp>=3.8.5
8
- Requires-Dist: aiounifi>=87.0.0
8
+ Requires-Dist: aiounifi>=88
9
9
  Requires-Dist: jsonschema>=4.17.0
10
10
  Requires-Dist: mcp[cli]>=1.21.2
11
11
  Requires-Dist: omegaconf>=2.3.0
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "unifi-network-mcp"
3
- version = "0.3.1"
3
+ dynamic = ["version"]
4
4
  description = "Unifi Network MCP Server"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
7
7
  dependencies = [
8
8
  "mcp[cli]>=1.21.2",
9
9
  "aiohttp>=3.8.5",
10
- "aiounifi>=87.0.0",
10
+ "aiounifi>=88",
11
11
  "pyyaml>=6.0",
12
12
  "python-dotenv>=1.0.0",
13
13
  "omegaconf>=2.3.0",
@@ -16,9 +16,15 @@ dependencies = [
16
16
  ]
17
17
 
18
18
  [build-system]
19
- requires = ["hatchling"]
19
+ requires = ["hatchling", "hatch-vcs"]
20
20
  build-backend = "hatchling.build"
21
21
 
22
+ [tool.hatch.version]
23
+ source = "vcs"
24
+
25
+ [tool.hatch.build.hooks.vcs]
26
+ version-file = "src/_version.py"
27
+
22
28
  [tool.hatch.build.targets.wheel]
23
29
  packages = ["src"]
24
30
 
@@ -44,6 +50,7 @@ dev = [
44
50
  "pytest-asyncio>=0.21.0",
45
51
  "aioresponses>=0.7.0",
46
52
  "pytest-cov>=4.0.0",
53
+ "ruff>=0.8.0",
47
54
  ]
48
55
 
49
56
  [tool.rye]
@@ -53,6 +60,7 @@ dev-dependencies = [
53
60
  "pytest-asyncio>=0.21.0",
54
61
  "aioresponses>=0.7.0",
55
62
  "pytest-cov>=4.0.0",
63
+ "ruff>=0.8.0",
56
64
  ]
57
65
 
58
66
  [project.scripts]
@@ -61,6 +69,7 @@ unifi-network-mcp = "src.main:main"
61
69
 
62
70
  [tool.ruff]
63
71
  line-length = 120
72
+ exclude = ["src/_version.py"] # Auto-generated by hatch-vcs
64
73
 
65
74
  [tool.ruff.lint]
66
75
  select = ["E", "F", "I"]
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.3.3'
32
+ __version_tuple__ = version_tuple = (0, 3, 3)
33
+
34
+ __commit_id__ = commit_id = None
@@ -13,11 +13,68 @@ from aiounifi.models.configuration import Configuration
13
13
  logger = logging.getLogger("unifi-network-mcp")
14
14
 
15
15
 
16
+ async def detect_unifi_os_pre_login(
17
+ session: aiohttp.ClientSession,
18
+ base_url: str,
19
+ timeout: int = 5,
20
+ ) -> Optional[bool]:
21
+ """
22
+ Detect UniFi OS BEFORE authentication using unauthenticated probes.
23
+
24
+ This detection determines which auth endpoint to use:
25
+ - UniFi OS: /api/auth/login
26
+ - Standalone: /api/login
27
+
28
+ Strategy:
29
+ 1. GET base URL - UniFi OS returns 200 with HTML, standalone redirects or errors
30
+ 2. Check for UniFi OS specific headers/behavior
31
+
32
+ Args:
33
+ session: Active aiohttp.ClientSession
34
+ base_url: Base URL of controller (e.g., 'https://192.168.1.1:443')
35
+ timeout: Detection timeout in seconds (default: 5)
36
+
37
+ Returns:
38
+ True: UniFi OS detected (use /api/auth/login)
39
+ False: Standalone controller (use /api/login)
40
+ None: Detection inconclusive
41
+ """
42
+ client_timeout = aiohttp.ClientTimeout(total=timeout)
43
+
44
+ try:
45
+ # Probe 1: GET base URL without following redirects
46
+ # UniFi OS typically returns 200 OK with the web UI
47
+ # Standalone controllers often redirect to /manage or return different status
48
+ async with session.get(base_url, timeout=client_timeout, ssl=False, allow_redirects=False) as response:
49
+ logger.debug(f"Pre-login probe {base_url}: status={response.status}")
50
+
51
+ if response.status == 200:
52
+ # UniFi OS returns 200 at base URL
53
+ logger.debug("Pre-login detection: UniFi OS (200 at base URL)")
54
+ return True
55
+ elif response.status in (301, 302, 303, 307, 308):
56
+ # Redirect typically indicates standalone controller
57
+ location = response.headers.get("Location", "")
58
+ logger.debug(f"Pre-login detection: redirect to {location}")
59
+ # Could be standalone redirecting to /manage
60
+ return False
61
+
62
+ except asyncio.TimeoutError:
63
+ logger.debug("Pre-login detection: timeout")
64
+ except aiohttp.ClientError as e:
65
+ logger.debug(f"Pre-login detection failed: {e}")
66
+ except Exception as e:
67
+ logger.debug(f"Pre-login detection unexpected error: {e}")
68
+
69
+ return None
70
+
71
+
16
72
  async def detect_with_retry(
17
73
  session: aiohttp.ClientSession,
18
74
  base_url: str,
19
75
  max_retries: int = 3,
20
76
  timeout: int = 5,
77
+ pre_login: bool = False,
21
78
  ) -> Optional[bool]:
22
79
  """
23
80
  Detect UniFi OS with exponential backoff retry.
@@ -27,6 +84,8 @@ async def detect_with_retry(
27
84
  base_url: Base URL of controller
28
85
  max_retries: Maximum retry attempts (default: 3)
29
86
  timeout: Detection timeout per attempt in seconds (default: 5)
87
+ pre_login: If True, use unauthenticated detection for auth endpoint selection.
88
+ If False, use authenticated detection for API path verification.
30
89
 
31
90
  Returns:
32
91
  True: UniFi OS detected
@@ -39,9 +98,11 @@ async def detect_with_retry(
39
98
  - Logs retry attempts at debug level
40
99
  - Returns None if all attempts fail
41
100
  """
101
+ detect_func = detect_unifi_os_pre_login if pre_login else detect_unifi_os_proactively
102
+
42
103
  for attempt in range(max_retries):
43
104
  try:
44
- result = await detect_unifi_os_proactively(session, base_url, timeout)
105
+ result = await detect_func(session, base_url, timeout)
45
106
  if result is not None:
46
107
  return result
47
108
  except Exception as e:
@@ -55,22 +116,6 @@ async def detect_with_retry(
55
116
  return None
56
117
 
57
118
 
58
- def _generate_detection_failure_message(base_url: str, port: int) -> str:
59
- """Generate user-friendly troubleshooting message for detection failures."""
60
- return f"""
61
- UniFi controller path detection failed.
62
-
63
- Troubleshooting Steps:
64
- 1. Verify network connectivity to {base_url}
65
- 2. Check controller is accessible on port {port}
66
- 3. Manually set controller type using environment variable:
67
- - For UniFi OS (Cloud Gateway, UDM-Pro): export UNIFI_CONTROLLER_TYPE=proxy
68
- - For standalone controllers: export UNIFI_CONTROLLER_TYPE=direct
69
-
70
- For more help, see: https://github.com/sirkirby/unifi-network-mcp/issues/19
71
- """
72
-
73
-
74
119
  async def _probe_endpoint(
75
120
  session: aiohttp.ClientSession,
76
121
  url: str,
@@ -233,7 +278,11 @@ class ConnectionManager:
233
278
  )
234
279
  session_created = True
235
280
 
236
- # Manual override configuration (FR-004: runs before login, no auth needed)
281
+ # Controller type detection/override configuration
282
+ # Two-phase detection:
283
+ # 1. Pre-login: Determines auth endpoint (/api/auth/login vs /api/login)
284
+ # 2. Post-login: Verifies API path prefix (/proxy/network/api vs /api)
285
+ # See: https://github.com/sirkirby/unifi-network-mcp/issues/33
237
286
  from src.bootstrap import UNIFI_CONTROLLER_TYPE
238
287
 
239
288
  if UNIFI_CONTROLLER_TYPE == "proxy":
@@ -242,6 +291,30 @@ class ConnectionManager:
242
291
  elif UNIFI_CONTROLLER_TYPE == "direct":
243
292
  self._unifi_os_override = False
244
293
  logger.info("Controller type forced to standard (direct) via config")
294
+ elif UNIFI_CONTROLLER_TYPE == "auto":
295
+ # Phase 1: Pre-login detection (unauthenticated)
296
+ # Determines which auth endpoint to use
297
+ if self._unifi_os_override is None:
298
+ detected = await detect_with_retry(
299
+ self._aiohttp_session,
300
+ self.url_base,
301
+ max_retries=3,
302
+ timeout=5,
303
+ pre_login=True, # Use unauthenticated detection
304
+ )
305
+ if detected is not None:
306
+ self._unifi_os_override = detected
307
+ mode = "UniFi OS (proxy)" if detected else "standard (direct)"
308
+ logger.info(f"Pre-login auto-detected controller type: {mode}")
309
+ else:
310
+ # Pre-login detection inconclusive - aiounifi will try its own detection
311
+ # Show helpful message for troubleshooting
312
+ logger.warning(
313
+ "Pre-login detection inconclusive, deferring to aiounifi. "
314
+ "If login fails, try setting UNIFI_CONTROLLER_TYPE=proxy for UniFi OS devices."
315
+ )
316
+ else:
317
+ logger.debug(f"Using cached detection result: {self._unifi_os_override}")
245
318
 
246
319
  config = Configuration(
247
320
  session=self._aiohttp_session,
@@ -254,30 +327,34 @@ class ConnectionManager:
254
327
 
255
328
  self.controller = Controller(config=config)
256
329
 
330
+ # Apply pre-login detection result BEFORE login to ensure correct auth endpoint
331
+ # aiounifi uses /api/auth/login for UniFi OS, /api/login for standalone
332
+ if self._unifi_os_override is not None:
333
+ self.controller.connectivity.is_unifi_os = self._unifi_os_override
334
+ logger.debug(f"Pre-login is_unifi_os set to: {self._unifi_os_override}")
335
+
257
336
  await self.controller.login()
258
337
 
259
- # Auto-detection (FR-002: runs after login for authenticated probes)
260
- if UNIFI_CONTROLLER_TYPE == "auto":
261
- # Check if already detected (session cache - FR-011)
262
- if self._unifi_os_override is None:
263
- # Proactive detection with retry (FR-001, FR-005, FR-008)
264
- detected = await detect_with_retry(
265
- self._aiohttp_session,
266
- self.url_base,
267
- max_retries=3,
268
- timeout=5,
338
+ # Phase 2: Post-login verification (authenticated)
339
+ # Verify API path prefix works correctly after successful login
340
+ if UNIFI_CONTROLLER_TYPE == "auto" and self._unifi_os_override is not None:
341
+ post_login_detected = await detect_with_retry(
342
+ self._aiohttp_session,
343
+ self.url_base,
344
+ max_retries=2,
345
+ timeout=5,
346
+ pre_login=False, # Use authenticated detection
347
+ )
348
+ if post_login_detected is not None and post_login_detected != self._unifi_os_override:
349
+ # Post-login detection differs - update override
350
+ logger.warning(
351
+ f"Post-login detection differs from pre-login: "
352
+ f"pre={self._unifi_os_override}, post={post_login_detected}. "
353
+ f"Using post-login result."
269
354
  )
270
- if detected is not None:
271
- self._unifi_os_override = detected
272
- mode = "UniFi OS (proxy)" if detected else "standard (direct)"
273
- logger.info(f"Auto-detected controller type: {mode}")
274
- else:
275
- # Show clear error message (FR-009)
276
- error_msg = _generate_detection_failure_message(self.url_base, self.port)
277
- logger.warning(error_msg)
278
- logger.warning("Falling back to aiounifi's check_unifi_os()")
279
- else:
280
- logger.debug(f"Using cached detection result: {self._unifi_os_override}")
355
+ self._unifi_os_override = post_login_detected
356
+ elif post_login_detected is not None:
357
+ logger.debug("Post-login detection confirmed pre-login result")
281
358
 
282
359
  self._initialized = True
283
360
  logger.info(f"Successfully connected to Unifi controller at {self.host} for site '{self.site}'")
@@ -170,7 +170,7 @@ async def get_network_details(network_id: str) -> Dict[str, Any]:
170
170
 
171
171
  @server.tool(
172
172
  name="unifi_update_network",
173
- description="Update specific fields of an existing network (LAN/VLAN). Requires confirmation.",
173
+ description="Update specific fields of an existing network (LAN/VLAN). Requires confirmation. Note: Network isolation is only supported on 'corporate' networks, not 'guest' networks.",
174
174
  permission_category="networks",
175
175
  permission_action="update",
176
176
  )
@@ -193,9 +193,15 @@ async def update_network(network_id: str, update_data: Dict[str, Any], confirm:
193
193
  - dhcp_start (string): New DHCP start IP.
194
194
  - dhcp_stop (string): New DHCP stop IP.
195
195
  - enabled (boolean): Enable/disable the entire network.
196
+ - network_isolation_enabled (boolean): Enable network isolation (IMPORTANT: Only works on networks with purpose="corporate").
196
197
  # Add other relevant fields from NetworkSchema here if needed
197
198
  confirm (bool): Must be set to `True` to execute. Defaults to `False`.
198
199
 
200
+ Important Constraints:
201
+ - Network isolation (network_isolation_enabled) is ONLY supported on networks with purpose="corporate".
202
+ - Attempting to enable isolation on "guest" or other network types will fail with an API error.
203
+ - To isolate a guest network: (1) Change its purpose from "guest" to "corporate", then (2) enable network_isolation_enabled.
204
+
199
205
  Returns:
200
206
  Dict: Success status, ID, updated fields, details, or error message.
201
207
  Example (success):
@@ -317,6 +323,11 @@ async def create_network(network_data: Dict[str, Any], confirm: bool = False) ->
317
323
  - vlan (integer): VLAN ID (required if vlan_enabled is true)
318
324
  - dhcp_enabled (boolean): Whether DHCP is enabled (default: true)
319
325
  - enabled (boolean): Whether the network is enabled (default: true)
326
+ - network_isolation_enabled (boolean): Enable network isolation (IMPORTANT: Only works on networks with purpose="corporate")
327
+
328
+ Important Constraints:
329
+ - Network isolation (network_isolation_enabled) is ONLY supported on networks with purpose="corporate".
330
+ - It cannot be enabled on "guest" networks.
320
331
 
321
332
  Example:
322
333
  {
@@ -1239,7 +1239,7 @@
1239
1239
  }
1240
1240
  },
1241
1241
  {
1242
- "description": "Update specific fields of an existing network (LAN/VLAN). Requires confirmation.",
1242
+ "description": "Update specific fields of an existing network (LAN/VLAN). Requires confirmation. Note: Network isolation is only supported on 'corporate' networks, not 'guest' networks.",
1243
1243
  "name": "unifi_update_network",
1244
1244
  "schema": {
1245
1245
  "input": {
@@ -7,12 +7,63 @@ when first called by an LLM. This dramatically reduces initial context usage.
7
7
  import importlib
8
8
  import logging
9
9
  from functools import wraps
10
+ from pathlib import Path
10
11
  from typing import Any, Callable, Dict, Set
11
12
 
12
13
  logger = logging.getLogger("unifi-network-mcp")
13
14
 
14
- # Tool module mapping: tool_name -> module_path
15
- TOOL_MODULE_MAP: Dict[str, str] = {
15
+
16
+ def _build_tool_module_map() -> Dict[str, str]:
17
+ """Build tool-to-module mapping by scanning tool files.
18
+
19
+ This dynamically discovers all tools and their modules, eliminating the need
20
+ for a manually-maintained static mapping that can get out of sync.
21
+ """
22
+ tool_map: Dict[str, str] = {}
23
+
24
+ # Find the tools directory
25
+ # Try relative to this file first, then fall back to cwd
26
+ this_dir = Path(__file__).parent
27
+ tools_dir = this_dir.parent / "tools"
28
+
29
+ if not tools_dir.exists():
30
+ tools_dir = Path("src/tools")
31
+
32
+ if not tools_dir.exists():
33
+ logger.warning("Tools directory not found, falling back to static map")
34
+ return _STATIC_TOOL_MODULE_MAP
35
+
36
+ # Scan each .py file in tools directory
37
+ for tool_file in tools_dir.glob("*.py"):
38
+ if tool_file.name.startswith("_"):
39
+ continue
40
+
41
+ module_name = f"src.tools.{tool_file.stem}"
42
+
43
+ try:
44
+ # Read file and look for @server.tool or @permissioned_tool decorators
45
+ content = tool_file.read_text()
46
+
47
+ # Find tool names using simple pattern matching
48
+ # Looking for: name="unifi_xxx" or name='unifi_xxx'
49
+ import re
50
+
51
+ pattern = r'name\s*=\s*["\']([unifi_][a-z_]+)["\']'
52
+ matches = re.findall(pattern, content)
53
+
54
+ for tool_name in matches:
55
+ if tool_name.startswith("unifi_"):
56
+ tool_map[tool_name] = module_name
57
+
58
+ except Exception as e:
59
+ logger.debug(f"Error scanning {tool_file}: {e}")
60
+
61
+ logger.debug(f"Built dynamic tool map with {len(tool_map)} tools")
62
+ return tool_map
63
+
64
+
65
+ # Static fallback map (used if dynamic discovery fails)
66
+ _STATIC_TOOL_MODULE_MAP: Dict[str, str] = {
16
67
  # Client tools
17
68
  "unifi_list_clients": "src.tools.clients",
18
69
  "unifi_get_client_details": "src.tools.clients",
@@ -61,15 +112,21 @@ TOOL_MODULE_MAP: Dict[str, str] = {
61
112
  "unifi_update_vpn_client_state": "src.tools.vpn",
62
113
  # QoS tools
63
114
  "unifi_list_qos_rules": "src.tools.qos",
115
+ "unifi_get_qos_rule_details": "src.tools.qos",
64
116
  "unifi_create_qos_rule": "src.tools.qos",
117
+ "unifi_create_simple_qos_rule": "src.tools.qos",
65
118
  "unifi_update_qos_rule": "src.tools.qos",
66
119
  "unifi_delete_qos_rule": "src.tools.qos",
120
+ "unifi_toggle_qos_rule_enabled": "src.tools.qos",
67
121
  # Statistics tools
68
122
  "unifi_get_client_stats": "src.tools.stats",
69
123
  "unifi_get_device_stats": "src.tools.stats",
70
124
  "unifi_get_network_stats": "src.tools.stats",
71
125
  "unifi_get_wireless_stats": "src.tools.stats",
72
126
  "unifi_get_system_stats": "src.tools.stats",
127
+ "unifi_get_top_clients": "src.tools.stats",
128
+ "unifi_get_dpi_stats": "src.tools.stats",
129
+ "unifi_get_alerts": "src.tools.stats",
73
130
  # System tools
74
131
  "unifi_get_system_info": "src.tools.system",
75
132
  "unifi_get_network_health": "src.tools.system",
@@ -101,8 +158,35 @@ TOOL_MODULE_MAP: Dict[str, str] = {
101
158
  "unifi_get_traffic_route_details": "src.tools.traffic_routes",
102
159
  "unifi_update_traffic_route": "src.tools.traffic_routes",
103
160
  "unifi_toggle_traffic_route": "src.tools.traffic_routes",
161
+ # Port Forward tools
162
+ "unifi_list_port_forwards": "src.tools.port_forwards",
163
+ "unifi_get_port_forward": "src.tools.port_forwards",
164
+ "unifi_create_port_forward": "src.tools.port_forwards",
165
+ "unifi_create_simple_port_forward": "src.tools.port_forwards",
166
+ "unifi_update_port_forward": "src.tools.port_forwards",
167
+ "unifi_toggle_port_forward": "src.tools.port_forwards",
168
+ # Firewall Policy tools (zone-based)
169
+ "unifi_list_firewall_policies": "src.tools.firewall",
170
+ "unifi_list_firewall_zones": "src.tools.firewall",
171
+ "unifi_list_ip_groups": "src.tools.firewall",
172
+ "unifi_get_firewall_policy_details": "src.tools.firewall",
173
+ "unifi_create_firewall_policy": "src.tools.firewall",
174
+ "unifi_create_simple_firewall_policy": "src.tools.firewall",
175
+ "unifi_update_firewall_policy": "src.tools.firewall",
176
+ "unifi_toggle_firewall_policy": "src.tools.firewall",
177
+ # WLAN tools
178
+ "unifi_list_wlans": "src.tools.network",
179
+ "unifi_get_wlan_details": "src.tools.network",
180
+ "unifi_create_wlan": "src.tools.network",
181
+ "unifi_update_wlan": "src.tools.network",
182
+ # Device tools (additional)
183
+ "unifi_rename_device": "src.tools.devices",
104
184
  }
105
185
 
186
+ # Build the tool map dynamically at module load time
187
+ # Falls back to static map if dynamic discovery fails
188
+ TOOL_MODULE_MAP: Dict[str, str] = _build_tool_module_map()
189
+
106
190
 
107
191
  class LazyToolLoader:
108
192
  """Manages lazy/on-demand tool loading."""