unifi-network-mcp 0.3.0__py3-none-any.whl → 0.3.2__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.
src/config/config.yaml CHANGED
@@ -12,13 +12,43 @@ server:
12
12
  port: 3000
13
13
  log_level: INFO
14
14
 
15
- # Tool registration mode (New in v0.2.0)
15
+ # Tool registration mode
16
16
  # Options: eager, lazy, meta_only
17
- # - eager: Register all 67 tools immediately (~5,000 tokens) - Best for dev console, automation
18
- # - lazy: Register 3 meta-tools, load others on-demand (~225 tokens) - RECOMMENDED for LLMs
19
- # - meta_only: Register only 3 meta-tools, manual discovery (~225 tokens) - Maximum control
17
+ # - eager: Register all tools immediately - Best for dev console, automation
18
+ # - lazy: Register meta-tools, load others on-demand - RECOMMENDED for LLMs
19
+ # - meta_only: Register only meta-tools, manual discovery - Maximum context savings
20
20
  tool_registration_mode: ${oc.env:UNIFI_TOOL_REGISTRATION_MODE,lazy}
21
21
 
22
+ # Tool filtering (optional, for eager mode)
23
+ # Use these to limit which tools are registered for automation/n8n workflows
24
+ #
25
+ # enabled_categories: Only load tools from these categories (module names in src/tools/)
26
+ # Valid values:
27
+ # - clients # Client listing, blocking, guest auth
28
+ # - config # Configuration management
29
+ # - devices # Device listing, reboot, locate, upgrade
30
+ # - events # Events and alarms
31
+ # - firewall # Firewall rules and groups
32
+ # - hotspot # Vouchers for guest network
33
+ # - network # Network/VLAN management
34
+ # - port_forwards # Port forwarding rules
35
+ # - qos # QoS/traffic shaping rules
36
+ # - routing # Static routes (V1 API)
37
+ # - stats # Statistics and metrics
38
+ # - system # System info, health, settings
39
+ # - traffic_routes # Policy-based routing (V2 API)
40
+ # - usergroups # Bandwidth profiles/user groups
41
+ # - vpn # VPN servers and clients
42
+ # Example: "clients,devices,system" or ["clients", "devices", "system"]
43
+ #
44
+ # enabled_tools: Only register these specific tools (use with enabled_categories or alone)
45
+ # Example: "unifi_list_clients,unifi_list_devices" or ["unifi_list_clients", "unifi_list_devices"]
46
+ #
47
+ # Note: Tools may also be filtered by the permissions section below (e.g., update: false)
48
+ # If neither is set, all tools are registered (default behavior)
49
+ enabled_categories: ${oc.env:UNIFI_ENABLED_CATEGORIES,null}
50
+ enabled_tools: ${oc.env:UNIFI_ENABLED_TOOLS,null}
51
+
22
52
  http:
23
53
  enabled: ${oc.env:UNIFI_MCP_HTTP_ENABLED,false}
24
54
  diagnostics:
src/main.py CHANGED
@@ -28,7 +28,7 @@ from src.runtime import (
28
28
  from src.tool_index import register_tool, tool_index_handler
29
29
  from src.utils.diagnostics import diagnostics_enabled, wrap_tool
30
30
  from src.utils.lazy_tool_loader import setup_lazy_loading
31
- from src.utils.meta_tools import register_meta_tools
31
+ from src.utils.meta_tools import register_load_tools, register_meta_tools
32
32
  from src.utils.permissions import parse_permission # noqa: E402
33
33
  from src.utils.tool_loader import auto_load_tools
34
34
 
@@ -77,17 +77,21 @@ def permissioned_tool(*d_args, **d_kwargs): # acts like @server.tool
77
77
  param_type = "string" # default
78
78
  if param.annotation != inspect.Parameter.empty:
79
79
  ann = param.annotation
80
- # Basic type mapping
81
- if ann in (int, "int"):
80
+ # Handle generic types like Dict[str, Any], List[str]
81
+ from typing import get_origin
82
+
83
+ origin = get_origin(ann)
84
+ # Basic type mapping (check origin first for generics)
85
+ if origin is dict or ann in (dict, "dict"):
86
+ param_type = "object"
87
+ elif origin is list or ann in (list, "list"):
88
+ param_type = "array"
89
+ elif ann in (int, "int"):
82
90
  param_type = "integer"
83
91
  elif ann in (bool, "bool"):
84
92
  param_type = "boolean"
85
93
  elif ann in (float, "float"):
86
94
  param_type = "number"
87
- elif ann in (list, "list"):
88
- param_type = "array"
89
- elif ann in (dict, "dict"):
90
- param_type = "object"
91
95
 
92
96
  properties[param_name] = {"type": param_type}
93
97
 
@@ -242,28 +246,58 @@ async def main_async():
242
246
  # Load full tool set based on registration mode
243
247
  if UNIFI_TOOL_REGISTRATION_MODE == "meta_only":
244
248
  logger.info("🔍 Tool registration mode: meta_only")
245
- logger.info(" Only meta-tools registered (unifi_tool_index, unifi_async_*)")
246
- logger.info(" Use unifi_tool_index to discover all 64+ available UniFi tools")
247
- logger.info(" 💡 This saves ~4,800 tokens vs. eager mode!")
248
- logger.info(" To load all tools: set UNIFI_TOOL_REGISTRATION_MODE=eager")
249
+ logger.info(" Meta-tools: unifi_tool_index, unifi_execute, unifi_batch, unifi_batch_status")
250
+ logger.info(" Use unifi_execute to run any tool discovered via unifi_tool_index")
251
+ logger.info(" To load all tools directly: set UNIFI_TOOL_REGISTRATION_MODE=eager")
249
252
  elif UNIFI_TOOL_REGISTRATION_MODE == "lazy":
250
- logger.info("⚡ Tool registration mode: lazy (NEW in v0.2.0!)")
251
- logger.info(" Meta-tools registered now")
252
- logger.info(" Other tools loaded on-demand when first called")
253
- logger.info(" 💡 Saves tokens AND provides seamless access to all tools!")
253
+ logger.info("⚡ Tool registration mode: lazy")
254
+ logger.info(" Meta-tools: unifi_tool_index, unifi_execute, unifi_batch, unifi_batch_status, unifi_load_tools")
255
+ logger.info(" Use unifi_execute to run any tool - works with all clients")
254
256
 
255
257
  # Setup lazy loading interceptor
256
- setup_lazy_loading(server, _original_tool_decorator)
258
+ lazy_loader = setup_lazy_loading(server, _original_tool_decorator)
259
+
260
+ # Register unifi_load_tools meta-tool (requires lazy_loader)
261
+ register_load_tools(
262
+ server=server,
263
+ tool_decorator=_original_tool_decorator,
264
+ lazy_loader=lazy_loader,
265
+ register_tool=register_tool,
266
+ )
257
267
 
258
- # All tools remain in the registry (for tool_index), but not registered with MCP yet
259
- # They'll be registered on first use
260
268
  from src.utils.lazy_tool_loader import TOOL_MODULE_MAP
261
269
 
262
270
  logger.info(f" Lazy loader ready - {len(TOOL_MODULE_MAP)} tools available on-demand")
263
271
  else: # eager (default)
264
272
  logger.info("📚 Tool registration mode: eager")
265
- logger.info(" All 64+ UniFi tools registered immediately")
266
- auto_load_tools()
273
+
274
+ # Check for tool filtering config
275
+ enabled_categories = config.server.get("enabled_categories")
276
+ enabled_tools = config.server.get("enabled_tools")
277
+
278
+ # Parse from comma-separated string if from env var
279
+ if isinstance(enabled_categories, str) and enabled_categories not in ("null", ""):
280
+ enabled_categories = [c.strip() for c in enabled_categories.split(",")]
281
+ elif enabled_categories in (None, "null", ""):
282
+ enabled_categories = None
283
+
284
+ if isinstance(enabled_tools, str) and enabled_tools not in ("null", ""):
285
+ enabled_tools = [t.strip() for t in enabled_tools.split(",")]
286
+ elif enabled_tools in (None, "null", ""):
287
+ enabled_tools = None
288
+
289
+ if enabled_categories:
290
+ logger.info(f" Filtering by categories: {enabled_categories}")
291
+ elif enabled_tools:
292
+ logger.info(f" Filtering to {len(enabled_tools)} specific tools")
293
+ else:
294
+ logger.info(" All tools registered (no filtering)")
295
+
296
+ auto_load_tools(
297
+ enabled_categories=enabled_categories,
298
+ enabled_tools=enabled_tools,
299
+ server=server,
300
+ )
267
301
 
268
302
  # List all registered tools for debugging
269
303
  try:
@@ -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}'")
src/tools_manifest.json CHANGED
@@ -119,7 +119,7 @@
119
119
  "type": "boolean"
120
120
  },
121
121
  "policy_data": {
122
- "type": "string"
122
+ "type": "object"
123
123
  }
124
124
  },
125
125
  "required": [
@@ -139,7 +139,7 @@
139
139
  "type": "boolean"
140
140
  },
141
141
  "network_data": {
142
- "type": "string"
142
+ "type": "object"
143
143
  }
144
144
  },
145
145
  "required": [
@@ -156,7 +156,7 @@
156
156
  "input": {
157
157
  "properties": {
158
158
  "port_forward_data": {
159
- "type": "string"
159
+ "type": "object"
160
160
  }
161
161
  },
162
162
  "required": [
@@ -176,7 +176,7 @@
176
176
  "type": "boolean"
177
177
  },
178
178
  "qos_data": {
179
- "type": "string"
179
+ "type": "object"
180
180
  }
181
181
  },
182
182
  "required": [
@@ -230,7 +230,7 @@
230
230
  "type": "boolean"
231
231
  },
232
232
  "policy": {
233
- "type": "string"
233
+ "type": "object"
234
234
  }
235
235
  },
236
236
  "required": [
@@ -250,7 +250,7 @@
250
250
  "type": "boolean"
251
251
  },
252
252
  "rule": {
253
- "type": "string"
253
+ "type": "object"
254
254
  }
255
255
  },
256
256
  "required": [
@@ -270,7 +270,7 @@
270
270
  "type": "boolean"
271
271
  },
272
272
  "rule": {
273
- "type": "string"
273
+ "type": "object"
274
274
  }
275
275
  },
276
276
  "required": [
@@ -351,7 +351,7 @@
351
351
  "type": "boolean"
352
352
  },
353
353
  "wlan_data": {
354
- "type": "string"
354
+ "type": "object"
355
355
  }
356
356
  },
357
357
  "required": [
@@ -1227,7 +1227,7 @@
1227
1227
  "type": "string"
1228
1228
  },
1229
1229
  "update_data": {
1230
- "type": "string"
1230
+ "type": "object"
1231
1231
  }
1232
1232
  },
1233
1233
  "required": [
@@ -1251,7 +1251,7 @@
1251
1251
  "type": "string"
1252
1252
  },
1253
1253
  "update_data": {
1254
- "type": "string"
1254
+ "type": "object"
1255
1255
  }
1256
1256
  },
1257
1257
  "required": [
@@ -1275,7 +1275,7 @@
1275
1275
  "type": "string"
1276
1276
  },
1277
1277
  "update_data": {
1278
- "type": "string"
1278
+ "type": "object"
1279
1279
  }
1280
1280
  },
1281
1281
  "required": [
@@ -1299,7 +1299,7 @@
1299
1299
  "type": "string"
1300
1300
  },
1301
1301
  "update_data": {
1302
- "type": "string"
1302
+ "type": "object"
1303
1303
  }
1304
1304
  },
1305
1305
  "required": [
@@ -1452,7 +1452,7 @@
1452
1452
  "type": "boolean"
1453
1453
  },
1454
1454
  "update_data": {
1455
- "type": "string"
1455
+ "type": "object"
1456
1456
  },
1457
1457
  "wlan_id": {
1458
1458
  "type": "string"
src/utils/diagnostics.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import inspect
3
4
  import json
4
5
  import logging
5
6
  import os
@@ -142,6 +143,12 @@ def log_tool_call(
142
143
 
143
144
 
144
145
  def wrap_tool(func, tool_name: str):
146
+ """Wrap a tool function with diagnostics logging.
147
+
148
+ IMPORTANT: Preserves the original function's signature so that FastMCP
149
+ can correctly generate the JSON schema for tool parameters.
150
+ """
151
+
145
152
  @wraps(func)
146
153
  async def _wrapper(*args, **kwargs):
147
154
  if not diagnostics_enabled():
@@ -163,6 +170,8 @@ def wrap_tool(func, tool_name: str):
163
170
  # Never let diagnostics break the tool
164
171
  pass
165
172
 
173
+ # Preserve the original function's signature for FastMCP schema generation
174
+ _wrapper.__signature__ = inspect.signature(func)
166
175
  return _wrapper
167
176
 
168
177
 
@@ -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."""