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 +34 -4
- src/main.py +54 -20
- src/managers/connection_manager.py +116 -39
- src/tools_manifest.json +13 -13
- src/utils/diagnostics.py +9 -0
- src/utils/lazy_tool_loader.py +86 -2
- src/utils/meta_tools.py +263 -124
- src/utils/tool_loader.py +74 -8
- {unifi_network_mcp-0.3.0.data → unifi_network_mcp-0.3.2.data}/data/share/unifi-network-mcp/.well-known/mcp-server.json +8 -4
- {unifi_network_mcp-0.3.0.dist-info → unifi_network_mcp-0.3.2.dist-info}/METADATA +131 -50
- {unifi_network_mcp-0.3.0.dist-info → unifi_network_mcp-0.3.2.dist-info}/RECORD +14 -14
- {unifi_network_mcp-0.3.0.dist-info → unifi_network_mcp-0.3.2.dist-info}/WHEEL +0 -0
- {unifi_network_mcp-0.3.0.dist-info → unifi_network_mcp-0.3.2.dist-info}/entry_points.txt +0 -0
- {unifi_network_mcp-0.3.0.dist-info → unifi_network_mcp-0.3.2.dist-info}/licenses/LICENSE +0 -0
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
|
|
15
|
+
# Tool registration mode
|
|
16
16
|
# Options: eager, lazy, meta_only
|
|
17
|
-
# - eager: Register all
|
|
18
|
-
# - lazy: Register
|
|
19
|
-
# - meta_only: Register only
|
|
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
|
-
#
|
|
81
|
-
|
|
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("
|
|
246
|
-
logger.info(" Use
|
|
247
|
-
logger.info("
|
|
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
|
|
251
|
-
logger.info(" Meta-tools
|
|
252
|
-
logger.info("
|
|
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
|
-
|
|
266
|
-
|
|
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
|
|
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}'")
|
src/tools_manifest.json
CHANGED
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
"type": "boolean"
|
|
120
120
|
},
|
|
121
121
|
"policy_data": {
|
|
122
|
-
"type": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
|
src/utils/lazy_tool_loader.py
CHANGED
|
@@ -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."""
|