unifi-network-mcp 0.4.2__tar.gz → 0.5.1__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.4.2 → unifi_network_mcp-0.5.1}/PKG-INFO +3 -1
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/README.md +2 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/_version.py +2 -2
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/config/config.yaml +7 -3
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/main.py +2 -1
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/runtime.py +32 -1
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools/clients.py +4 -4
- unifi_network_mcp-0.5.1/src/tools/system.py +168 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools_manifest.json +119 -1
- unifi_network_mcp-0.5.1/src/utils/lazy_tool_loader.py +214 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/utils/permissions.py +1 -0
- unifi_network_mcp-0.4.2/src/tools/system.py +0 -76
- unifi_network_mcp-0.4.2/src/utils/lazy_tool_loader.py +0 -300
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/.gitignore +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/.well-known/mcp-server.json +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/LICENSE +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/pyproject.toml +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/bootstrap.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/jobs.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/managers/client_manager.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/managers/connection_manager.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/managers/device_manager.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/managers/event_manager.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/managers/firewall_manager.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/managers/hotspot_manager.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/managers/network_manager.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/managers/qos_manager.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/managers/routing_manager.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/managers/stats_manager.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/managers/system_manager.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/managers/traffic_route_manager.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/managers/usergroup_manager.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/managers/vpn_manager.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/schemas.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tool_index.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools/config.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools/devices.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools/events.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools/firewall.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools/hotspot.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools/network.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools/port_forwards.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools/qos.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools/routing.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools/stats.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools/traffic_routes.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools/usergroups.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/tools/vpn.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/utils/config_helpers.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/utils/confirmation.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/utils/diagnostics.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/utils/meta_tools.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/utils/tool_loader.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/validator_registry.py +0 -0
- {unifi_network_mcp-0.4.2 → unifi_network_mcp-0.5.1}/src/validators.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unifi-network-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Unifi Network MCP Server
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.13
|
|
@@ -534,6 +534,8 @@ The server merges settings from **environment variables**, an optional `.env` fi
|
|
|
534
534
|
| `UNIFI_VERIFY_SSL` | Set to `false` if using self-signed certs |
|
|
535
535
|
| `UNIFI_CONTROLLER_TYPE` | Controller API path type: `auto` (detect), `proxy` (UniFi OS), `direct` (standalone). Default `auto` |
|
|
536
536
|
| `UNIFI_MCP_HTTP_ENABLED` | Set `true` to enable optional HTTP SSE server (default `false`) |
|
|
537
|
+
| `UNIFI_MCP_HOST` | HTTP SSE bind address (default `0.0.0.0`) |
|
|
538
|
+
| `UNIFI_MCP_PORT` | HTTP SSE bind port (default `3000`) |
|
|
537
539
|
| `UNIFI_AUTO_CONFIRM` | Set `true` to auto-confirm all mutating operations (skips preview step). Ideal for workflow automation (n8n, Make, Zapier). Default `false` |
|
|
538
540
|
| `UNIFI_TOOL_REGISTRATION_MODE` | Tool loading mode: `lazy` (default), `eager`, or `meta_only`. See [Context Optimization](#context-optimization) |
|
|
539
541
|
| `UNIFI_ENABLED_CATEGORIES` | Comma-separated list of tool categories to load (eager mode). See table below |
|
|
@@ -518,6 +518,8 @@ The server merges settings from **environment variables**, an optional `.env` fi
|
|
|
518
518
|
| `UNIFI_VERIFY_SSL` | Set to `false` if using self-signed certs |
|
|
519
519
|
| `UNIFI_CONTROLLER_TYPE` | Controller API path type: `auto` (detect), `proxy` (UniFi OS), `direct` (standalone). Default `auto` |
|
|
520
520
|
| `UNIFI_MCP_HTTP_ENABLED` | Set `true` to enable optional HTTP SSE server (default `false`) |
|
|
521
|
+
| `UNIFI_MCP_HOST` | HTTP SSE bind address (default `0.0.0.0`) |
|
|
522
|
+
| `UNIFI_MCP_PORT` | HTTP SSE bind port (default `3000`) |
|
|
521
523
|
| `UNIFI_AUTO_CONFIRM` | Set `true` to auto-confirm all mutating operations (skips preview step). Ideal for workflow automation (n8n, Make, Zapier). Default `false` |
|
|
522
524
|
| `UNIFI_TOOL_REGISTRATION_MODE` | Tool loading mode: `lazy` (default), `eager`, or `meta_only`. See [Context Optimization](#context-optimization) |
|
|
523
525
|
| `UNIFI_ENABLED_CATEGORIES` | Comma-separated list of tool categories to load (eager mode). See table below |
|
|
@@ -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.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.5.1'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 5, 1)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -8,8 +8,8 @@ unifi:
|
|
|
8
8
|
controller_type: ${oc.env:UNIFI_CONTROLLER_TYPE,auto}
|
|
9
9
|
|
|
10
10
|
server:
|
|
11
|
-
host: 0.0.0.0
|
|
12
|
-
port: 3000
|
|
11
|
+
host: ${oc.env:UNIFI_MCP_HOST,0.0.0.0}
|
|
12
|
+
port: ${oc.env:UNIFI_MCP_PORT,3000}
|
|
13
13
|
log_level: INFO
|
|
14
14
|
|
|
15
15
|
# Tool registration mode
|
|
@@ -118,4 +118,8 @@ permissions:
|
|
|
118
118
|
|
|
119
119
|
routes:
|
|
120
120
|
create: false # Static routes can disrupt connectivity
|
|
121
|
-
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
|
|
@@ -35,7 +35,8 @@ from src.utils.meta_tools import register_load_tools, register_meta_tools
|
|
|
35
35
|
from src.utils.permissions import parse_permission # noqa: E402
|
|
36
36
|
from src.utils.tool_loader import auto_load_tools
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
# Use the original FastMCP tool decorator (saved in runtime.py before wrapping)
|
|
39
|
+
_original_tool_decorator = getattr(server, "_original_tool", server.tool)
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
def permissioned_tool(*d_args, **d_kwargs): # acts like @server.tool
|
|
@@ -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
|
-
|
|
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:
|
|
489
|
-
fixed_ip:
|
|
490
|
-
local_dns_record_enabled:
|
|
491
|
-
local_dns_record:
|
|
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":
|
|
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,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
|
|
@@ -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
|
|
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
|