unifi-network-mcp 0.4.1__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/PKG-INFO +1 -1
  2. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/_version.py +2 -2
  3. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/config/config.yaml +6 -1
  4. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/main.py +16 -13
  5. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/runtime.py +32 -1
  6. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools/clients.py +4 -4
  7. unifi_network_mcp-0.5.0/src/tools/system.py +168 -0
  8. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools_manifest.json +119 -1
  9. unifi_network_mcp-0.5.0/src/utils/config_helpers.py +38 -0
  10. unifi_network_mcp-0.5.0/src/utils/lazy_tool_loader.py +214 -0
  11. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/utils/permissions.py +1 -0
  12. unifi_network_mcp-0.4.1/src/tools/system.py +0 -76
  13. unifi_network_mcp-0.4.1/src/utils/lazy_tool_loader.py +0 -300
  14. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/.gitignore +0 -0
  15. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/.well-known/mcp-server.json +0 -0
  16. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/LICENSE +0 -0
  17. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/README.md +0 -0
  18. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/pyproject.toml +0 -0
  19. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/bootstrap.py +0 -0
  20. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/jobs.py +0 -0
  21. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/managers/client_manager.py +0 -0
  22. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/managers/connection_manager.py +0 -0
  23. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/managers/device_manager.py +0 -0
  24. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/managers/event_manager.py +0 -0
  25. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/managers/firewall_manager.py +0 -0
  26. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/managers/hotspot_manager.py +0 -0
  27. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/managers/network_manager.py +0 -0
  28. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/managers/qos_manager.py +0 -0
  29. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/managers/routing_manager.py +0 -0
  30. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/managers/stats_manager.py +0 -0
  31. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/managers/system_manager.py +0 -0
  32. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/managers/traffic_route_manager.py +0 -0
  33. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/managers/usergroup_manager.py +0 -0
  34. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/managers/vpn_manager.py +0 -0
  35. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/schemas.py +0 -0
  36. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tool_index.py +0 -0
  37. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools/config.py +0 -0
  38. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools/devices.py +0 -0
  39. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools/events.py +0 -0
  40. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools/firewall.py +0 -0
  41. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools/hotspot.py +0 -0
  42. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools/network.py +0 -0
  43. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools/port_forwards.py +0 -0
  44. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools/qos.py +0 -0
  45. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools/routing.py +0 -0
  46. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools/stats.py +0 -0
  47. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools/traffic_routes.py +0 -0
  48. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools/usergroups.py +0 -0
  49. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/tools/vpn.py +0 -0
  50. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/utils/confirmation.py +0 -0
  51. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/utils/diagnostics.py +0 -0
  52. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/utils/meta_tools.py +0 -0
  53. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/utils/tool_loader.py +0 -0
  54. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/validator_registry.py +0 -0
  55. {unifi_network_mcp-0.4.1 → unifi_network_mcp-0.5.0}/src/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-network-mcp
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: Unifi Network MCP Server
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.13
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.4.1'
32
- __version_tuple__ = version_tuple = (0, 4, 1)
31
+ __version__ = version = '0.5.0'
32
+ __version_tuple__ = version_tuple = (0, 5, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -51,6 +51,7 @@ server:
51
51
 
52
52
  http:
53
53
  enabled: ${oc.env:UNIFI_MCP_HTTP_ENABLED,false}
54
+ force: ${oc.env:UNIFI_MCP_HTTP_FORCE,false}
54
55
  diagnostics:
55
56
  enabled: ${oc.env:UNIFI_MCP_DIAGNOSTICS,false}
56
57
  log_tool_args: ${oc.env:UNIFI_MCP_DIAG_LOG_TOOL_ARGS,true}
@@ -117,4 +118,8 @@ permissions:
117
118
 
118
119
  routes:
119
120
  create: false # Static routes can disrupt connectivity
120
- update: false # Modifying routes requires careful planning
121
+ update: false # Modifying routes requires careful planning
122
+
123
+ snmp:
124
+ create: false # Not applicable
125
+ update: true # Allow enabling/disabling SNMP and changing community
@@ -10,9 +10,11 @@ Responsibilities:
10
10
  import asyncio
11
11
  import logging
12
12
  import os
13
- import sys # Removed uvicorn import
13
+ import sys
14
14
  import traceback
15
15
 
16
+ import uvicorn.config
17
+
16
18
  from src.bootstrap import (
17
19
  UNIFI_TOOL_REGISTRATION_MODE,
18
20
  logger,
@@ -26,13 +28,15 @@ from src.runtime import (
26
28
  server,
27
29
  )
28
30
  from src.tool_index import register_tool, tool_index_handler
31
+ from src.utils.config_helpers import parse_config_bool
29
32
  from src.utils.diagnostics import diagnostics_enabled, wrap_tool
30
33
  from src.utils.lazy_tool_loader import setup_lazy_loading
31
34
  from src.utils.meta_tools import register_load_tools, register_meta_tools
32
35
  from src.utils.permissions import parse_permission # noqa: E402
33
36
  from src.utils.tool_loader import auto_load_tools
34
37
 
35
- _original_tool_decorator = server.tool # keep reference to wrap later
38
+ # Use the original FastMCP tool decorator (saved in runtime.py before wrapping)
39
+ _original_tool_decorator = getattr(server, "_original_tool", server.tool)
36
40
 
37
41
 
38
42
  def permissioned_tool(*d_args, **d_kwargs): # acts like @server.tool
@@ -310,17 +314,16 @@ async def main_async():
310
314
  host = config.server.get("host", "0.0.0.0")
311
315
  port = int(config.server.get("port", 3000))
312
316
  http_cfg = config.server.get("http", {})
313
- http_enabled_raw = http_cfg.get("enabled", False)
314
- if isinstance(http_enabled_raw, str):
315
- http_enabled = http_enabled_raw.strip().lower() in {"1", "true", "yes", "on"}
316
- else:
317
- http_enabled = bool(http_enabled_raw)
317
+ http_enabled = parse_config_bool(http_cfg.get("enabled", False))
318
318
 
319
- # Only the main container process (PID 1) should bind the HTTP SSE port.
319
+ # Only the main container process (PID 1) should bind the HTTP SSE port,
320
+ # unless http.force=true is set in config (for local development/testing).
321
+ force_http = parse_config_bool(http_cfg.get("force", False))
320
322
  is_main_container_process = os.getpid() == 1
321
- if http_enabled and not is_main_container_process:
323
+ if http_enabled and not is_main_container_process and not force_http:
322
324
  logger.info(
323
- "HTTP SSE enabled in config but skipped in exec session (PID %s != 1)",
325
+ "HTTP SSE enabled in config but skipped in exec session (PID %s != 1). "
326
+ "Set UNIFI_MCP_HTTP_FORCE=true to override.",
324
327
  os.getpid(),
325
328
  )
326
329
  http_enabled = False
@@ -338,9 +341,9 @@ async def main_async():
338
341
  server.settings.host = host
339
342
  server.settings.port = port
340
343
 
341
- # Disable uvicorn access logging to prevent stdout conflicts
342
- # when running alongside stdio transport
343
- logging.getLogger("uvicorn.access").disabled = True
344
+ # Redirect uvicorn access logs to stderr to prevent stdout conflicts
345
+ # when running alongside stdio transport (stdout is used for JSON-RPC)
346
+ uvicorn.config.LOGGING_CONFIG["handlers"]["access"]["stream"] = "ext://sys.stderr"
344
347
 
345
348
  await server.run_sse_async()
346
349
  logger.info("HTTP SSE started via run_sse_async() using server.settings host/port.")
@@ -12,6 +12,10 @@ Downstream code (tool modules, tests, etc.) should import these via::
12
12
 
13
13
  Lazy factories (`get_*`) are provided so unit tests can substitute fakes by
14
14
  monkey‑patching before the first call.
15
+
16
+ IMPORTANT: The server's `tool` decorator is wrapped here (not in main.py) to
17
+ ensure that tool modules can be imported directly (for testing, etc.) without
18
+ errors from unrecognized decorator kwargs like `permission_category`.
15
19
  """
16
20
 
17
21
  import os
@@ -49,6 +53,25 @@ def get_config():
49
53
  return load_config()
50
54
 
51
55
 
56
+ def _create_permissioned_tool_wrapper(original_tool_decorator):
57
+ """Wrap the FastMCP tool decorator to handle permission kwargs.
58
+
59
+ This wrapper strips `permission_category` and `permission_action` kwargs
60
+ before passing to the original FastMCP decorator. This allows tool modules
61
+ to be imported directly (for testing, etc.) without errors.
62
+
63
+ The actual permission checking is done in main.py's permissioned_tool,
64
+ which replaces this wrapper at startup. This wrapper just ensures imports
65
+ don't fail when tools have permission kwargs.
66
+ """
67
+ def wrapper(*args, **kwargs):
68
+ # Strip permission-related kwargs that FastMCP doesn't understand
69
+ kwargs.pop("permission_category", None)
70
+ kwargs.pop("permission_action", None)
71
+ return original_tool_decorator(*args, **kwargs)
72
+ return wrapper
73
+
74
+
52
75
  @lru_cache
53
76
  def get_server() -> FastMCP:
54
77
  """Create the FastMCP server instance exactly once."""
@@ -62,12 +85,20 @@ def get_server() -> FastMCP:
62
85
 
63
86
  logger.debug(f"Configuring FastMCP with allowed_hosts: {allowed_hosts}")
64
87
 
65
- return FastMCP(
88
+ server = FastMCP(
66
89
  name="unifi-network-mcp",
67
90
  debug=True,
68
91
  transport_security=transport_security,
69
92
  )
70
93
 
94
+ # Wrap the tool decorator to handle permission kwargs gracefully.
95
+ # This ensures tool modules can be imported directly without errors.
96
+ # main.py will replace this with the full permissioned_tool implementation.
97
+ server._original_tool = server.tool
98
+ server.tool = _create_permissioned_tool_wrapper(server.tool)
99
+
100
+ return server
101
+
71
102
 
72
103
  # ---------------------------------------------------------------------------
73
104
  # Manager factories ---------------------------------------------------------
@@ -485,10 +485,10 @@ Either setting can be enabled/disabled independently.""",
485
485
  )
486
486
  async def set_client_ip_settings(
487
487
  mac_address: str,
488
- use_fixedip: Optional[bool] = None,
489
- fixed_ip: Optional[str] = None,
490
- local_dns_record_enabled: Optional[bool] = None,
491
- local_dns_record: Optional[str] = None,
488
+ use_fixedip: bool | None = None,
489
+ fixed_ip: str | None = None,
490
+ local_dns_record_enabled: bool | None = None,
491
+ local_dns_record: str | None = None,
492
492
  confirm: bool = False,
493
493
  ) -> Dict[str, Any]:
494
494
  """Set fixed IP and/or local DNS record for a client."""
@@ -0,0 +1,168 @@
1
+ """
2
+ Unifi Network MCP system tools.
3
+
4
+ This module provides MCP tools to interact with a Unifi Network Controller's system functions.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any, Dict, Optional
9
+
10
+ from src.runtime import config, server, system_manager
11
+ from src.utils.confirmation import should_auto_confirm, update_preview
12
+ from src.utils.permissions import parse_permission
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Explicitly retrieve and log the server instance to confirm it's being used
17
+ logger.info(f"System tools module loaded, server instance: {server}")
18
+
19
+
20
+ @server.tool(
21
+ name="unifi_get_system_info",
22
+ description="Get general system information from the Unifi Network controller (version, uptime, etc).",
23
+ )
24
+ async def get_system_info() -> Dict[str, Any]:
25
+ """Implementation for getting system info."""
26
+ logger.info("unifi_get_system_info tool called")
27
+ try:
28
+ info = await system_manager.get_system_info()
29
+ return {
30
+ "success": True,
31
+ "site": system_manager._connection.site,
32
+ "system_info": info,
33
+ }
34
+ except Exception as e:
35
+ logger.error(f"Error getting system info: {e}", exc_info=True)
36
+ return {"success": False, "error": str(e)}
37
+
38
+
39
+ @server.tool(
40
+ name="unifi_get_network_health",
41
+ description="Get the current network health summary (WAN status, device counts).",
42
+ )
43
+ async def get_network_health() -> Dict[str, Any]:
44
+ """Implementation for getting network health."""
45
+ logger.info("unifi_get_network_health tool called")
46
+ try:
47
+ health = await system_manager.get_network_health()
48
+ return {
49
+ "success": True,
50
+ "site": system_manager._connection.site,
51
+ "health_summary": health,
52
+ }
53
+ except Exception as e:
54
+ logger.error(f"Error getting network health: {e}", exc_info=True)
55
+ return {"success": False, "error": str(e)}
56
+
57
+
58
+ @server.tool(
59
+ name="unifi_get_site_settings",
60
+ description="Get current site settings (e.g., country code, timezone, connectivity monitoring).",
61
+ )
62
+ async def get_site_settings() -> Dict[str, Any]:
63
+ """Implementation for getting site settings."""
64
+ logger.info("unifi_get_site_settings tool called")
65
+ try:
66
+ settings = await system_manager.get_site_settings()
67
+ return {
68
+ "success": True,
69
+ "site": system_manager._connection.site,
70
+ "site_settings": settings,
71
+ }
72
+ except Exception as e:
73
+ logger.error(f"Error getting site settings: {e}", exc_info=True)
74
+ return {"success": False, "error": str(e)}
75
+
76
+
77
+ @server.tool(
78
+ name="unifi_get_snmp_settings",
79
+ description="Get current SNMP settings for the site (enabled state, community string).",
80
+ )
81
+ async def get_snmp_settings() -> Dict[str, Any]:
82
+ """Implementation for getting SNMP settings."""
83
+ logger.info("unifi_get_snmp_settings tool called")
84
+ try:
85
+ settings_list = await system_manager.get_settings("snmp")
86
+ snmp_settings = settings_list[0] if settings_list else {}
87
+ return {
88
+ "success": True,
89
+ "site": system_manager._connection.site,
90
+ "snmp_settings": {
91
+ "enabled": snmp_settings.get("enabled", False),
92
+ "community": snmp_settings.get("community", ""),
93
+ },
94
+ }
95
+ except Exception as e:
96
+ logger.error(f"Error getting SNMP settings: {e}", exc_info=True)
97
+ return {"success": False, "error": str(e)}
98
+
99
+
100
+ @server.tool(
101
+ name="unifi_update_snmp_settings",
102
+ description="Update SNMP settings for the site (enable/disable, set community string). Requires confirm=true to apply changes.",
103
+ )
104
+ async def update_snmp_settings(
105
+ enabled: bool,
106
+ community: Optional[str] = None,
107
+ confirm: bool = False,
108
+ ) -> Dict[str, Any]:
109
+ """Implementation for updating SNMP settings.
110
+
111
+ Args:
112
+ enabled: Whether SNMP should be enabled on the site.
113
+ community: SNMP community string (optional, keeps current value if not provided).
114
+ confirm: Must be true to apply changes. When false, returns a preview of proposed changes.
115
+ """
116
+ logger.info(f"unifi_update_snmp_settings tool called (enabled={enabled}, confirm={confirm})")
117
+
118
+ if not parse_permission(config.permissions, "snmp", "update"):
119
+ logger.warning("Permission denied for updating SNMP settings.")
120
+ return {"success": False, "error": "Permission denied to update SNMP settings."}
121
+
122
+ try:
123
+ settings_list = await system_manager.get_settings("snmp")
124
+ current = settings_list[0] if settings_list else {}
125
+
126
+ updates: Dict[str, Any] = {"enabled": enabled}
127
+ if community is not None:
128
+ updates["community"] = community
129
+
130
+ if not confirm and not should_auto_confirm():
131
+ return update_preview(
132
+ resource_type="snmp_settings",
133
+ resource_id=current.get("_id", "snmp"),
134
+ resource_name="SNMP Settings",
135
+ current_state={
136
+ "enabled": current.get("enabled", False),
137
+ "community": current.get("community", ""),
138
+ },
139
+ updates=updates,
140
+ )
141
+
142
+ payload: Dict[str, Any] = {"enabled": enabled}
143
+ if community is not None:
144
+ payload["community"] = community
145
+
146
+ success = await system_manager.update_settings("snmp", payload)
147
+ if success:
148
+ refreshed = await system_manager.get_settings("snmp")
149
+ new_settings = refreshed[0] if refreshed else payload
150
+ return {
151
+ "success": True,
152
+ "site": system_manager._connection.site,
153
+ "snmp_settings": {
154
+ "enabled": new_settings.get("enabled", enabled),
155
+ "community": new_settings.get("community", community or ""),
156
+ },
157
+ }
158
+ return {"success": False, "error": "Failed to update SNMP settings."}
159
+ except Exception as e:
160
+ logger.error(f"Error updating SNMP settings: {e}", exc_info=True)
161
+ return {"success": False, "error": str(e)}
162
+
163
+
164
+ # Print confirmation that all tools have been registered
165
+ logger.info(
166
+ "System tools registered: unifi_get_system_info, unifi_get_network_health, "
167
+ "unifi_get_site_settings, unifi_get_snmp_settings, unifi_update_snmp_settings"
168
+ )
@@ -1,6 +1,91 @@
1
1
  {
2
- "count": 81,
2
+ "count": 83,
3
3
  "generated_by": "scripts/generate_tool_manifest.py",
4
+ "module_map": {
5
+ "unifi_adopt_device": "src.tools.devices",
6
+ "unifi_archive_alarm": "src.tools.events",
7
+ "unifi_archive_all_alarms": "src.tools.events",
8
+ "unifi_authorize_guest": "src.tools.clients",
9
+ "unifi_block_client": "src.tools.clients",
10
+ "unifi_create_firewall_policy": "src.tools.firewall",
11
+ "unifi_create_network": "src.tools.network",
12
+ "unifi_create_port_forward": "src.tools.port_forwards",
13
+ "unifi_create_qos_rule": "src.tools.qos",
14
+ "unifi_create_route": "src.tools.routing",
15
+ "unifi_create_simple_firewall_policy": "src.tools.firewall",
16
+ "unifi_create_simple_port_forward": "src.tools.port_forwards",
17
+ "unifi_create_simple_qos_rule": "src.tools.qos",
18
+ "unifi_create_usergroup": "src.tools.usergroups",
19
+ "unifi_create_voucher": "src.tools.hotspot",
20
+ "unifi_create_wlan": "src.tools.network",
21
+ "unifi_force_reconnect_client": "src.tools.clients",
22
+ "unifi_get_alerts": "src.tools.stats",
23
+ "unifi_get_client_details": "src.tools.clients",
24
+ "unifi_get_client_stats": "src.tools.stats",
25
+ "unifi_get_device_details": "src.tools.devices",
26
+ "unifi_get_device_stats": "src.tools.stats",
27
+ "unifi_get_dpi_stats": "src.tools.stats",
28
+ "unifi_get_event_types": "src.tools.events",
29
+ "unifi_get_firewall_policy_details": "src.tools.firewall",
30
+ "unifi_get_network_details": "src.tools.network",
31
+ "unifi_get_network_health": "src.tools.system",
32
+ "unifi_get_network_stats": "src.tools.stats",
33
+ "unifi_get_port_forward": "src.tools.port_forwards",
34
+ "unifi_get_qos_rule_details": "src.tools.qos",
35
+ "unifi_get_route_details": "src.tools.routing",
36
+ "unifi_get_site_settings": "src.tools.config",
37
+ "unifi_get_snmp_settings": "src.tools.system",
38
+ "unifi_get_system_info": "src.tools.system",
39
+ "unifi_get_top_clients": "src.tools.stats",
40
+ "unifi_get_traffic_route_details": "src.tools.traffic_routes",
41
+ "unifi_get_usergroup_details": "src.tools.usergroups",
42
+ "unifi_get_voucher_details": "src.tools.hotspot",
43
+ "unifi_get_vpn_client_details": "src.tools.vpn",
44
+ "unifi_get_vpn_server_details": "src.tools.vpn",
45
+ "unifi_get_wlan_details": "src.tools.network",
46
+ "unifi_list_active_routes": "src.tools.routing",
47
+ "unifi_list_alarms": "src.tools.events",
48
+ "unifi_list_blocked_clients": "src.tools.clients",
49
+ "unifi_list_clients": "src.tools.clients",
50
+ "unifi_list_devices": "src.tools.devices",
51
+ "unifi_list_events": "src.tools.events",
52
+ "unifi_list_firewall_policies": "src.tools.firewall",
53
+ "unifi_list_firewall_zones": "src.tools.firewall",
54
+ "unifi_list_ip_groups": "src.tools.firewall",
55
+ "unifi_list_networks": "src.tools.network",
56
+ "unifi_list_port_forwards": "src.tools.port_forwards",
57
+ "unifi_list_qos_rules": "src.tools.qos",
58
+ "unifi_list_routes": "src.tools.routing",
59
+ "unifi_list_traffic_routes": "src.tools.traffic_routes",
60
+ "unifi_list_usergroups": "src.tools.usergroups",
61
+ "unifi_list_vouchers": "src.tools.hotspot",
62
+ "unifi_list_vpn_clients": "src.tools.vpn",
63
+ "unifi_list_vpn_servers": "src.tools.vpn",
64
+ "unifi_list_wlans": "src.tools.network",
65
+ "unifi_reboot_device": "src.tools.devices",
66
+ "unifi_rename_client": "src.tools.clients",
67
+ "unifi_rename_device": "src.tools.devices",
68
+ "unifi_revoke_voucher": "src.tools.hotspot",
69
+ "unifi_set_client_ip_settings": "src.tools.clients",
70
+ "unifi_toggle_firewall_policy": "src.tools.firewall",
71
+ "unifi_toggle_port_forward": "src.tools.port_forwards",
72
+ "unifi_toggle_qos_rule_enabled": "src.tools.qos",
73
+ "unifi_toggle_traffic_route": "src.tools.traffic_routes",
74
+ "unifi_unauthorize_guest": "src.tools.clients",
75
+ "unifi_unblock_client": "src.tools.clients",
76
+ "unifi_update_firewall_policy": "src.tools.firewall",
77
+ "unifi_update_network": "src.tools.network",
78
+ "unifi_update_port_forward": "src.tools.port_forwards",
79
+ "unifi_update_qos_rule": "src.tools.qos",
80
+ "unifi_update_route": "src.tools.routing",
81
+ "unifi_update_snmp_settings": "src.tools.system",
82
+ "unifi_update_traffic_route": "src.tools.traffic_routes",
83
+ "unifi_update_usergroup": "src.tools.usergroups",
84
+ "unifi_update_vpn_client_state": "src.tools.vpn",
85
+ "unifi_update_vpn_server_state": "src.tools.vpn",
86
+ "unifi_update_wlan": "src.tools.network",
87
+ "unifi_upgrade_device": "src.tools.devices"
88
+ },
4
89
  "note": "Auto-generated with full schemas from tool decorators. Do not edit manually.",
5
90
  "tools": [
6
91
  {
@@ -611,6 +696,16 @@
611
696
  }
612
697
  }
613
698
  },
699
+ {
700
+ "description": "Get current SNMP settings for the site (enabled state, community string).",
701
+ "name": "unifi_get_snmp_settings",
702
+ "schema": {
703
+ "input": {
704
+ "properties": {},
705
+ "type": "object"
706
+ }
707
+ }
708
+ },
614
709
  {
615
710
  "description": "Get general system information from the Unifi Network controller (version, uptime, etc).",
616
711
  "name": "unifi_get_system_info",
@@ -1345,6 +1440,29 @@
1345
1440
  }
1346
1441
  }
1347
1442
  },
1443
+ {
1444
+ "description": "Update SNMP settings for the site (enable/disable, set community string). Requires confirm=true to apply changes.",
1445
+ "name": "unifi_update_snmp_settings",
1446
+ "schema": {
1447
+ "input": {
1448
+ "properties": {
1449
+ "community": {
1450
+ "type": "string"
1451
+ },
1452
+ "confirm": {
1453
+ "type": "boolean"
1454
+ },
1455
+ "enabled": {
1456
+ "type": "boolean"
1457
+ }
1458
+ },
1459
+ "required": [
1460
+ "enabled"
1461
+ ],
1462
+ "type": "object"
1463
+ }
1464
+ }
1465
+ },
1348
1466
  {
1349
1467
  "description": "Update a traffic route's settings.\n\nCan update:\n- enabled: Enable or disable the traffic route\n- kill_switch_enabled: Enable/disable the kill switch (blocks traffic if VPN is down)\n\nAt least one parameter must be provided.",
1350
1468
  "name": "unifi_update_traffic_route",
@@ -0,0 +1,38 @@
1
+ """Configuration parsing utilities.
2
+
3
+ This module provides helpers for parsing configuration values that may come
4
+ from OmegaConf-resolved environment variables (which can be strings like
5
+ "true"/"false") or direct boolean values.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+
11
+ def parse_config_bool(value: Any, default: bool = False) -> bool:
12
+ """Parse a config value as boolean, handling string values from env vars.
13
+
14
+ OmegaConf resolves ${oc.env:VAR,default} to strings when the env var is set,
15
+ so "true"/"false" strings need to be parsed explicitly.
16
+
17
+ Args:
18
+ value: The config value to parse (string, bool, or None)
19
+ default: Default value if value is None
20
+
21
+ Returns:
22
+ Boolean interpretation of the value
23
+
24
+ Examples:
25
+ >>> parse_config_bool("true")
26
+ True
27
+ >>> parse_config_bool("false")
28
+ False
29
+ >>> parse_config_bool(True)
30
+ True
31
+ >>> parse_config_bool(None, default=True)
32
+ True
33
+ """
34
+ if value is None:
35
+ return default
36
+ if isinstance(value, str):
37
+ return value.strip().lower() in {"1", "true", "yes", "on"}
38
+ return bool(value)
@@ -0,0 +1,214 @@
1
+ """Lazy tool loader for on-demand tool registration.
2
+
3
+ This module implements true lazy loading of tools, registering them only
4
+ when first called by an LLM. This dramatically reduces initial context usage.
5
+ """
6
+
7
+ import importlib
8
+ import json
9
+ import logging
10
+ import re
11
+ from functools import wraps
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Dict, Set
14
+
15
+ logger = logging.getLogger("unifi-network-mcp")
16
+
17
+
18
+ def _load_module_map_from_manifest() -> Dict[str, str]:
19
+ """Load tool-to-module mapping from the manifest file.
20
+
21
+ The manifest is auto-generated by scripts/generate_tool_manifest.py and
22
+ includes a module_map that stays in sync with the actual tools.
23
+
24
+ Returns:
25
+ Dictionary mapping tool names to their module paths, or empty dict if unavailable
26
+ """
27
+ # Try relative to this file first
28
+ manifest_path = Path(__file__).parent.parent / "tools_manifest.json"
29
+
30
+ if not manifest_path.exists():
31
+ # Try relative to cwd
32
+ manifest_path = Path("src/tools_manifest.json")
33
+
34
+ if not manifest_path.exists():
35
+ logger.warning("Tools manifest not found for fallback loading")
36
+ return {}
37
+
38
+ try:
39
+ with open(manifest_path) as f:
40
+ manifest = json.load(f)
41
+ module_map = manifest.get("module_map", {})
42
+ logger.info(f"Loaded module map from manifest with {len(module_map)} tools")
43
+ return module_map
44
+ except Exception as e:
45
+ logger.warning(f"Failed to load module map from manifest: {e}")
46
+ return {}
47
+
48
+
49
+ def _build_tool_module_map() -> Dict[str, str]:
50
+ """Build tool-to-module mapping by scanning tool files.
51
+
52
+ This dynamically discovers all tools and their modules, eliminating the need
53
+ for a manually-maintained static mapping that can get out of sync.
54
+
55
+ Falls back to loading from tools_manifest.json if the tools directory
56
+ is not found (e.g., in unusual deployment scenarios).
57
+ """
58
+ tool_map: Dict[str, str] = {}
59
+
60
+ # Find the tools directory
61
+ # Try relative to this file first, then fall back to cwd
62
+ this_dir = Path(__file__).parent
63
+ tools_dir = this_dir.parent / "tools"
64
+
65
+ if not tools_dir.exists():
66
+ tools_dir = Path("src/tools")
67
+
68
+ if not tools_dir.exists():
69
+ logger.warning("Tools directory not found, falling back to manifest")
70
+ return _load_module_map_from_manifest()
71
+
72
+ # Scan each .py file in tools directory
73
+ for tool_file in tools_dir.glob("*.py"):
74
+ if tool_file.name.startswith("_"):
75
+ continue
76
+
77
+ module_name = f"src.tools.{tool_file.stem}"
78
+
79
+ try:
80
+ # Read file and look for @server.tool or @permissioned_tool decorators
81
+ content = tool_file.read_text()
82
+
83
+ # Find tool names using simple pattern matching
84
+ # Looking for: name="unifi_xxx" or name='unifi_xxx'
85
+ # Note: pattern uses literal 'unifi_' prefix, not a character class
86
+ pattern = r'name\s*=\s*["\'](unifi_[a-z_]+)["\']'
87
+ matches = re.findall(pattern, content)
88
+
89
+ for tool_name in matches:
90
+ if tool_name.startswith("unifi_"):
91
+ tool_map[tool_name] = module_name
92
+
93
+ except Exception as e:
94
+ logger.debug(f"Error scanning {tool_file}: {e}")
95
+
96
+ logger.debug(f"Built dynamic tool map with {len(tool_map)} tools")
97
+ return tool_map
98
+
99
+
100
+ # Build the tool map dynamically at module load time
101
+ # Falls back to manifest if dynamic discovery fails
102
+ TOOL_MODULE_MAP: Dict[str, str] = _build_tool_module_map()
103
+
104
+
105
+ class LazyToolLoader:
106
+ """Manages lazy/on-demand tool loading."""
107
+
108
+ def __init__(self, server, tool_decorator: Callable):
109
+ """Initialize the lazy tool loader.
110
+
111
+ Args:
112
+ server: FastMCP server instance
113
+ tool_decorator: The decorator function to register tools
114
+ """
115
+ self.server = server
116
+ self.tool_decorator = tool_decorator
117
+ self.loaded_modules: Set[str] = set()
118
+ self.loaded_tools: Set[str] = set()
119
+ self._loading = False
120
+
121
+ logger.info("Lazy tool loader initialized")
122
+
123
+ def is_loaded(self, tool_name: str) -> bool:
124
+ """Check if a tool is already loaded."""
125
+ return tool_name in self.loaded_tools
126
+
127
+ async def load_tool(self, tool_name: str) -> bool:
128
+ """Load a tool on-demand.
129
+
130
+ Args:
131
+ tool_name: Name of the tool to load
132
+
133
+ Returns:
134
+ True if tool was loaded successfully, False otherwise
135
+ """
136
+ # Avoid recursive loading
137
+ if self._loading:
138
+ return False
139
+
140
+ if self.is_loaded(tool_name):
141
+ logger.debug(f"Tool '{tool_name}' already loaded")
142
+ return True
143
+
144
+ module_path = TOOL_MODULE_MAP.get(tool_name)
145
+ if not module_path:
146
+ logger.warning(f"No module mapping found for tool '{tool_name}'")
147
+ return False
148
+
149
+ try:
150
+ self._loading = True
151
+ logger.info(f"🔄 Lazy-loading tool '{tool_name}' from '{module_path}'")
152
+
153
+ # Import the module (this will trigger @server.tool decorators)
154
+ if module_path not in self.loaded_modules:
155
+ importlib.import_module(module_path)
156
+ self.loaded_modules.add(module_path)
157
+
158
+ # Mark tool as loaded
159
+ self.loaded_tools.add(tool_name)
160
+
161
+ logger.info(f"✅ Tool '{tool_name}' loaded successfully")
162
+ return True
163
+
164
+ except Exception as e:
165
+ logger.error(f"Failed to load tool '{tool_name}': {e}", exc_info=True)
166
+ return False
167
+ finally:
168
+ self._loading = False
169
+
170
+ async def intercept_call_tool(self, original_call_tool: Callable, name: str, arguments: dict) -> Any:
171
+ """Intercept tool calls to load tools on-demand.
172
+
173
+ Args:
174
+ original_call_tool: Original call_tool method
175
+ name: Tool name
176
+ arguments: Tool arguments
177
+
178
+ Returns:
179
+ Result from the tool execution
180
+ """
181
+ # Try to load the tool if not already loaded
182
+ if not self.is_loaded(name) and name in TOOL_MODULE_MAP:
183
+ loaded = await self.load_tool(name)
184
+ if not loaded:
185
+ raise ValueError(f"Failed to load tool '{name}'")
186
+
187
+ # Call the original method
188
+ return await original_call_tool(name, arguments)
189
+
190
+
191
+ def setup_lazy_loading(server, tool_decorator: Callable) -> LazyToolLoader:
192
+ """Setup lazy tool loading by intercepting call_tool.
193
+
194
+ Args:
195
+ server: FastMCP server instance
196
+ tool_decorator: The decorator function to register tools
197
+
198
+ Returns:
199
+ LazyToolLoader instance
200
+ """
201
+ loader = LazyToolLoader(server, tool_decorator)
202
+
203
+ # Intercept call_tool to load tools on-demand
204
+ original_call_tool = server.call_tool
205
+
206
+ @wraps(original_call_tool)
207
+ async def lazy_call_tool(name: str, arguments: dict):
208
+ return await loader.intercept_call_tool(original_call_tool, name, arguments)
209
+
210
+ server.call_tool = lazy_call_tool
211
+
212
+ logger.info("✨ Lazy tool loading enabled - tools will be loaded on first use")
213
+
214
+ return loader
@@ -35,6 +35,7 @@ CATEGORY_MAP = {
35
35
  "voucher": "vouchers",
36
36
  "usergroup": "usergroups",
37
37
  "route": "routes",
38
+ "snmp": "snmp",
38
39
  }
39
40
 
40
41
  DEFAULT_PERMISSIONS_KEY = "default"
@@ -1,76 +0,0 @@
1
- """
2
- Unifi Network MCP system tools.
3
-
4
- This module provides MCP tools to interact with a Unifi Network Controller's system functions.
5
- """
6
-
7
- import logging
8
- from typing import Any, Dict
9
-
10
- from src.runtime import server, system_manager
11
-
12
- logger = logging.getLogger(__name__)
13
-
14
- # Explicitly retrieve and log the server instance to confirm it's being used
15
- logger.info(f"System tools module loaded, server instance: {server}")
16
-
17
-
18
- @server.tool(
19
- name="unifi_get_system_info",
20
- description="Get general system information from the Unifi Network controller (version, uptime, etc).",
21
- )
22
- async def get_system_info() -> Dict[str, Any]:
23
- """Implementation for getting system info."""
24
- logger.info("unifi_get_system_info tool called")
25
- try:
26
- info = await system_manager.get_system_info()
27
- return {
28
- "success": True,
29
- "site": system_manager._connection.site,
30
- "system_info": info,
31
- }
32
- except Exception as e:
33
- logger.error(f"Error getting system info: {e}", exc_info=True)
34
- return {"success": False, "error": str(e)}
35
-
36
-
37
- @server.tool(
38
- name="unifi_get_network_health",
39
- description="Get the current network health summary (WAN status, device counts).",
40
- )
41
- async def get_network_health() -> Dict[str, Any]:
42
- """Implementation for getting network health."""
43
- logger.info("unifi_get_network_health tool called")
44
- try:
45
- health = await system_manager.get_network_health()
46
- return {
47
- "success": True,
48
- "site": system_manager._connection.site,
49
- "health_summary": health,
50
- }
51
- except Exception as e:
52
- logger.error(f"Error getting network health: {e}", exc_info=True)
53
- return {"success": False, "error": str(e)}
54
-
55
-
56
- @server.tool(
57
- name="unifi_get_site_settings",
58
- description="Get current site settings (e.g., country code, timezone, connectivity monitoring).",
59
- )
60
- async def get_site_settings() -> Dict[str, Any]:
61
- """Implementation for getting site settings."""
62
- logger.info("unifi_get_site_settings tool called")
63
- try:
64
- settings = await system_manager.get_site_settings()
65
- return {
66
- "success": True,
67
- "site": system_manager._connection.site,
68
- "site_settings": settings,
69
- }
70
- except Exception as e:
71
- logger.error(f"Error getting site settings: {e}", exc_info=True)
72
- return {"success": False, "error": str(e)}
73
-
74
-
75
- # Print confirmation that all tools have been registered
76
- logger.info("System tools registered: unifi_get_system_info, unifi_get_network_health, unifi_get_site_settings")
@@ -1,300 +0,0 @@
1
- """Lazy tool loader for on-demand tool registration.
2
-
3
- This module implements true lazy loading of tools, registering them only
4
- when first called by an LLM. This dramatically reduces initial context usage.
5
- """
6
-
7
- import importlib
8
- import logging
9
- from functools import wraps
10
- from pathlib import Path
11
- from typing import Any, Callable, Dict, Set
12
-
13
- logger = logging.getLogger("unifi-network-mcp")
14
-
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] = {
67
- # Client tools
68
- "unifi_list_clients": "src.tools.clients",
69
- "unifi_get_client_details": "src.tools.clients",
70
- "unifi_list_blocked_clients": "src.tools.clients",
71
- "unifi_block_client": "src.tools.clients",
72
- "unifi_unblock_client": "src.tools.clients",
73
- "unifi_rename_client": "src.tools.clients",
74
- "unifi_force_reconnect_client": "src.tools.clients",
75
- "unifi_authorize_guest": "src.tools.clients",
76
- "unifi_unauthorize_guest": "src.tools.clients",
77
- "unifi_set_client_ip_settings": "src.tools.clients",
78
- # Device tools
79
- "unifi_list_devices": "src.tools.devices",
80
- "unifi_get_device_details": "src.tools.devices",
81
- "unifi_reboot_device": "src.tools.devices",
82
- "unifi_locate_device": "src.tools.devices",
83
- "unifi_adopt_device": "src.tools.devices",
84
- "unifi_upgrade_device": "src.tools.devices",
85
- "unifi_set_device_name": "src.tools.devices",
86
- "unifi_power_cycle_port": "src.tools.devices",
87
- "unifi_set_port_profile": "src.tools.devices",
88
- # Network tools
89
- "unifi_list_networks": "src.tools.networks",
90
- "unifi_get_network_details": "src.tools.networks",
91
- "unifi_create_network": "src.tools.networks",
92
- "unifi_update_network": "src.tools.networks",
93
- "unifi_delete_network": "src.tools.networks",
94
- # Firewall tools
95
- "unifi_list_firewall_rules": "src.tools.firewall",
96
- "unifi_get_firewall_rule": "src.tools.firewall",
97
- "unifi_create_firewall_rule": "src.tools.firewall",
98
- "unifi_update_firewall_rule": "src.tools.firewall",
99
- "unifi_delete_firewall_rule": "src.tools.firewall",
100
- "unifi_enable_firewall_rule": "src.tools.firewall",
101
- "unifi_disable_firewall_rule": "src.tools.firewall",
102
- "unifi_list_firewall_groups": "src.tools.firewall",
103
- "unifi_create_firewall_group": "src.tools.firewall",
104
- "unifi_update_firewall_group": "src.tools.firewall",
105
- "unifi_delete_firewall_group": "src.tools.firewall",
106
- # VPN tools
107
- "unifi_list_vpn_servers": "src.tools.vpn",
108
- "unifi_get_vpn_server_details": "src.tools.vpn",
109
- "unifi_update_vpn_server_state": "src.tools.vpn",
110
- "unifi_list_vpn_clients": "src.tools.vpn",
111
- "unifi_get_vpn_client_details": "src.tools.vpn",
112
- "unifi_update_vpn_client_state": "src.tools.vpn",
113
- # QoS tools
114
- "unifi_list_qos_rules": "src.tools.qos",
115
- "unifi_get_qos_rule_details": "src.tools.qos",
116
- "unifi_create_qos_rule": "src.tools.qos",
117
- "unifi_create_simple_qos_rule": "src.tools.qos",
118
- "unifi_update_qos_rule": "src.tools.qos",
119
- "unifi_delete_qos_rule": "src.tools.qos",
120
- "unifi_toggle_qos_rule_enabled": "src.tools.qos",
121
- # Statistics tools
122
- "unifi_get_client_stats": "src.tools.stats",
123
- "unifi_get_device_stats": "src.tools.stats",
124
- "unifi_get_network_stats": "src.tools.stats",
125
- "unifi_get_wireless_stats": "src.tools.stats",
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",
130
- # System tools
131
- "unifi_get_system_info": "src.tools.system",
132
- "unifi_get_network_health": "src.tools.system",
133
- "unifi_get_site_settings": "src.tools.system",
134
- # Event tools
135
- "unifi_list_events": "src.tools.events",
136
- "unifi_list_alarms": "src.tools.events",
137
- "unifi_get_event_types": "src.tools.events",
138
- "unifi_archive_alarm": "src.tools.events",
139
- "unifi_archive_all_alarms": "src.tools.events",
140
- # Hotspot/Voucher tools
141
- "unifi_list_vouchers": "src.tools.hotspot",
142
- "unifi_get_voucher_details": "src.tools.hotspot",
143
- "unifi_create_voucher": "src.tools.hotspot",
144
- "unifi_revoke_voucher": "src.tools.hotspot",
145
- # User group tools
146
- "unifi_list_usergroups": "src.tools.usergroups",
147
- "unifi_get_usergroup_details": "src.tools.usergroups",
148
- "unifi_create_usergroup": "src.tools.usergroups",
149
- "unifi_update_usergroup": "src.tools.usergroups",
150
- # Static Routing tools (V1 API)
151
- "unifi_list_routes": "src.tools.routing",
152
- "unifi_list_active_routes": "src.tools.routing",
153
- "unifi_get_route_details": "src.tools.routing",
154
- "unifi_create_route": "src.tools.routing",
155
- "unifi_update_route": "src.tools.routing",
156
- # Traffic Route tools (V2 API - policy-based routing)
157
- "unifi_list_traffic_routes": "src.tools.traffic_routes",
158
- "unifi_get_traffic_route_details": "src.tools.traffic_routes",
159
- "unifi_update_traffic_route": "src.tools.traffic_routes",
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",
184
- }
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
-
190
-
191
- class LazyToolLoader:
192
- """Manages lazy/on-demand tool loading."""
193
-
194
- def __init__(self, server, tool_decorator: Callable):
195
- """Initialize the lazy tool loader.
196
-
197
- Args:
198
- server: FastMCP server instance
199
- tool_decorator: The decorator function to register tools
200
- """
201
- self.server = server
202
- self.tool_decorator = tool_decorator
203
- self.loaded_modules: Set[str] = set()
204
- self.loaded_tools: Set[str] = set()
205
- self._loading = False
206
-
207
- logger.info("Lazy tool loader initialized")
208
-
209
- def is_loaded(self, tool_name: str) -> bool:
210
- """Check if a tool is already loaded."""
211
- return tool_name in self.loaded_tools
212
-
213
- async def load_tool(self, tool_name: str) -> bool:
214
- """Load a tool on-demand.
215
-
216
- Args:
217
- tool_name: Name of the tool to load
218
-
219
- Returns:
220
- True if tool was loaded successfully, False otherwise
221
- """
222
- # Avoid recursive loading
223
- if self._loading:
224
- return False
225
-
226
- if self.is_loaded(tool_name):
227
- logger.debug(f"Tool '{tool_name}' already loaded")
228
- return True
229
-
230
- module_path = TOOL_MODULE_MAP.get(tool_name)
231
- if not module_path:
232
- logger.warning(f"No module mapping found for tool '{tool_name}'")
233
- return False
234
-
235
- try:
236
- self._loading = True
237
- logger.info(f"🔄 Lazy-loading tool '{tool_name}' from '{module_path}'")
238
-
239
- # Import the module (this will trigger @server.tool decorators)
240
- if module_path not in self.loaded_modules:
241
- importlib.import_module(module_path)
242
- self.loaded_modules.add(module_path)
243
-
244
- # Mark tool as loaded
245
- self.loaded_tools.add(tool_name)
246
-
247
- logger.info(f"✅ Tool '{tool_name}' loaded successfully")
248
- return True
249
-
250
- except Exception as e:
251
- logger.error(f"Failed to load tool '{tool_name}': {e}", exc_info=True)
252
- return False
253
- finally:
254
- self._loading = False
255
-
256
- async def intercept_call_tool(self, original_call_tool: Callable, name: str, arguments: dict) -> Any:
257
- """Intercept tool calls to load tools on-demand.
258
-
259
- Args:
260
- original_call_tool: Original call_tool method
261
- name: Tool name
262
- arguments: Tool arguments
263
-
264
- Returns:
265
- Result from the tool execution
266
- """
267
- # Try to load the tool if not already loaded
268
- if not self.is_loaded(name) and name in TOOL_MODULE_MAP:
269
- loaded = await self.load_tool(name)
270
- if not loaded:
271
- raise ValueError(f"Failed to load tool '{name}'")
272
-
273
- # Call the original method
274
- return await original_call_tool(name, arguments)
275
-
276
-
277
- def setup_lazy_loading(server, tool_decorator: Callable) -> LazyToolLoader:
278
- """Setup lazy tool loading by intercepting call_tool.
279
-
280
- Args:
281
- server: FastMCP server instance
282
- tool_decorator: The decorator function to register tools
283
-
284
- Returns:
285
- LazyToolLoader instance
286
- """
287
- loader = LazyToolLoader(server, tool_decorator)
288
-
289
- # Intercept call_tool to load tools on-demand
290
- original_call_tool = server.call_tool
291
-
292
- @wraps(original_call_tool)
293
- async def lazy_call_tool(name: str, arguments: dict):
294
- return await loader.intercept_call_tool(original_call_tool, name, arguments)
295
-
296
- server.call_tool = lazy_call_tool
297
-
298
- logger.info("✨ Lazy tool loading enabled - tools will be loaded on first use")
299
-
300
- return loader