unifi-network-mcp 0.3.1__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.
@@ -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}'")
@@ -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."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-network-mcp
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Unifi Network MCP Server
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.13
@@ -7,7 +7,7 @@ src/tool_index.py,sha256=aUPFqES4i_cq9ZsfAoiWuhKwoEEJ8NEAOafQ2h1g1NE,5492
7
7
  src/validator_registry.py,sha256=dboNY36ZQphCr7tDQuVQtNvXu4X1FBkgqoLb-sGNwJQ,3005
8
8
  src/validators.py,sha256=JPjwRbs-FgyGQCAONVAiP2KoF5lWjwQ18gHD37PVBW8,2173
9
9
  src/managers/client_manager.py,sha256=2ckwXJoeTzL6RutOXpuBOyWy4N8hKJsh6oEQui11JZ8,14001
10
- src/managers/connection_manager.py,sha256=IOGBdyqrkHReMZQC3Ql0Ugu0uKvcmDIqHeY5-XmHT1E,22630
10
+ src/managers/connection_manager.py,sha256=DdElxZXdfJk7BBfHyrr3ueBUWtz31OVY5UvXFvnTmHs,26632
11
11
  src/managers/device_manager.py,sha256=-B9fNKzG1W8_vEw8zH8GirhaAMc1OywnzRnLlgLNfsM,4672
12
12
  src/managers/event_manager.py,sha256=Ud4I-eb0LzmT7_ZKq099po7CIRc3vHr6xzmxa6sEyOs,5865
13
13
  src/managers/firewall_manager.py,sha256=so_UZyn76Xe1epCKUGzbm0cpe8R540c_-D3PysqezNg,30258
@@ -37,15 +37,15 @@ src/tools/usergroups.py,sha256=S5tIj99YHmStuyQPgD-h1-Do9JvbZpM8bFk8XfuON2k,8459
37
37
  src/tools/vpn.py,sha256=aBvACvdm-xQcEFFK42JzYlG9OSmwqA2QIvsb6d-UG_s,7500
38
38
  src/utils/confirmation.py,sha256=_GfvEaiyWIC53wmfVHFo8pcjGwmzajnc7EuuqvZ63RI,7045
39
39
  src/utils/diagnostics.py,sha256=kDQlVHm5SLUU7t_X4F4zamHSCUujTLrMIploVBTdTTU,5673
40
- src/utils/lazy_tool_loader.py,sha256=r-Tz8tjfpuGMiSQYpnRmMqqXutYctpF1yHyEdF9NX5A,8228
40
+ src/utils/lazy_tool_loader.py,sha256=0GdfF7z06U_pvx7hmUE1jWEOFMFFo3WS9PWEEwkpXG4,11549
41
41
  src/utils/meta_tools.py,sha256=XlLkzFEvrZsDEiXxMq_Lz22ZGZ8mMzbjx6krYJS-JD4,13172
42
42
  src/utils/permissions.py,sha256=GGg81gCPBo18Ra9NWGgHO3pPzX1EFUAt_Xd9gbUfKQo,4060
43
43
  src/utils/tool_loader.py,sha256=QpfHOxyZ_bzPJM0SItB7UM8uW_p7vl2Sm25rfgfwsAY,4147
44
44
  src/tools_manifest.json,sha256=vOlP1YzEQn9wXNeBMd4zKlCpPvC4uyoE8Nw64aTb2Wc,37813
45
45
  src/config/config.yaml,sha256=FPoDva21XrUqgiwXTahVa5tDy_aNTeOL3cT8DTxyICU,3953
46
- unifi_network_mcp-0.3.1.data/data/share/unifi-network-mcp/.well-known/mcp-server.json,sha256=Ru2oF_SdOzkathJVTQ6R0bRB-NTmiXLlNVvsp7GqmPs,1427
47
- unifi_network_mcp-0.3.1.dist-info/METADATA,sha256=7QO-oXapLXsYEP2hZj4hDN70vdEndy2KFCxnLKpk1Ls,38286
48
- unifi_network_mcp-0.3.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
49
- unifi_network_mcp-0.3.1.dist-info/entry_points.txt,sha256=iDs5JXOBoUROpkuTXoxr-1WSrz1FLEgI0TO4pFsZ8u0,52
50
- unifi_network_mcp-0.3.1.dist-info/licenses/LICENSE,sha256=1tMHOACX4Nse1DzgSKufrvTGztT8kS71LE8l29IAvto,1068
51
- unifi_network_mcp-0.3.1.dist-info/RECORD,,
46
+ unifi_network_mcp-0.3.2.data/data/share/unifi-network-mcp/.well-known/mcp-server.json,sha256=Ru2oF_SdOzkathJVTQ6R0bRB-NTmiXLlNVvsp7GqmPs,1427
47
+ unifi_network_mcp-0.3.2.dist-info/METADATA,sha256=WeJg9PFrum4Ba5BQAYEuaJI20mqFpc2BE19YMxeq3Fo,38286
48
+ unifi_network_mcp-0.3.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
49
+ unifi_network_mcp-0.3.2.dist-info/entry_points.txt,sha256=iDs5JXOBoUROpkuTXoxr-1WSrz1FLEgI0TO4pFsZ8u0,52
50
+ unifi_network_mcp-0.3.2.dist-info/licenses/LICENSE,sha256=1tMHOACX4Nse1DzgSKufrvTGztT8kS71LE8l29IAvto,1068
51
+ unifi_network_mcp-0.3.2.dist-info/RECORD,,