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.
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/.gitignore +1 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/PKG-INFO +2 -2
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/pyproject.toml +12 -3
- unifi_network_mcp-0.3.3/src/_version.py +34 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/connection_manager.py +116 -39
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/network.py +12 -1
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools_manifest.json +1 -1
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/utils/lazy_tool_loader.py +86 -2
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/.well-known/mcp-server.json +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/LICENSE +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/README.md +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/bootstrap.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/config/config.yaml +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/jobs.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/main.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/client_manager.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/device_manager.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/event_manager.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/firewall_manager.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/hotspot_manager.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/network_manager.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/qos_manager.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/routing_manager.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/stats_manager.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/system_manager.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/traffic_route_manager.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/usergroup_manager.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/managers/vpn_manager.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/runtime.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/schemas.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tool_index.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/clients.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/config.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/devices.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/events.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/firewall.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/hotspot.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/port_forwards.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/qos.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/routing.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/stats.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/system.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/traffic_routes.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/usergroups.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/tools/vpn.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/utils/confirmation.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/utils/diagnostics.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/utils/meta_tools.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/utils/permissions.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/utils/tool_loader.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/validator_registry.py +0 -0
- {unifi_network_mcp-0.3.1 → unifi_network_mcp-0.3.3}/src/validators.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unifi-network-mcp
|
|
3
|
-
Version: 0.3.
|
|
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>=
|
|
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
|
-
|
|
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>=
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|