unifi-network-mcp 0.2.0__tar.gz → 0.3.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.
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/PKG-INFO +3 -1
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/README.md +2 -0
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/pyproject.toml +13 -1
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/bootstrap.py +8 -20
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/config/config.yaml +17 -1
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/jobs.py +1 -3
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/main.py +20 -45
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/client_manager.py +112 -40
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/connection_manager.py +40 -121
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/device_manager.py +9 -19
- unifi_network_mcp-0.3.0/src/managers/event_manager.py +182 -0
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/firewall_manager.py +63 -186
- unifi_network_mcp-0.3.0/src/managers/hotspot_manager.py +196 -0
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/network_manager.py +28 -81
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/qos_manager.py +10 -25
- unifi_network_mcp-0.3.0/src/managers/routing_manager.py +232 -0
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/stats_manager.py +24 -65
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/managers/system_manager.py +50 -143
- unifi_network_mcp-0.3.0/src/managers/traffic_route_manager.py +191 -0
- unifi_network_mcp-0.3.0/src/managers/usergroup_manager.py +186 -0
- unifi_network_mcp-0.3.0/src/managers/vpn_manager.py +304 -0
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/runtime.py +41 -6
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/schemas.py +2 -4
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tool_index.py +4 -8
- unifi_network_mcp-0.3.0/src/tools/clients.py +578 -0
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/config.py +4 -9
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/devices.py +149 -48
- unifi_network_mcp-0.3.0/src/tools/events.py +181 -0
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/firewall.py +89 -138
- unifi_network_mcp-0.3.0/src/tools/hotspot.py +232 -0
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/network.py +85 -100
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/port_forwards.py +64 -125
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/qos.py +79 -95
- unifi_network_mcp-0.3.0/src/tools/routing.py +332 -0
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/stats.py +18 -55
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/system.py +2 -4
- unifi_network_mcp-0.3.0/src/tools/traffic_routes.py +242 -0
- unifi_network_mcp-0.3.0/src/tools/usergroups.py +238 -0
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools/vpn.py +14 -38
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/tools_manifest.json +384 -21
- unifi_network_mcp-0.3.0/src/utils/confirmation.py +218 -0
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/utils/diagnostics.py +6 -13
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/utils/lazy_tool_loader.py +37 -7
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/utils/meta_tools.py +2 -6
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/utils/permissions.py +6 -4
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/validator_registry.py +22 -35
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/validators.py +5 -8
- unifi_network_mcp-0.2.0/src/managers/vpn_manager.py +0 -206
- unifi_network_mcp-0.2.0/src/tools/clients.py +0 -342
- unifi_network_mcp-0.2.0/src/tools/traffic_routes.py +0 -705
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/.gitignore +0 -0
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/.well-known/mcp-server.json +0 -0
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/LICENSE +0 -0
- {unifi_network_mcp-0.2.0 → unifi_network_mcp-0.3.0}/src/utils/tool_loader.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.3.0
|
|
4
4
|
Summary: Unifi Network MCP Server
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.13
|
|
@@ -62,6 +62,7 @@ A self-hosted [Model Context Protocol](https://github.com/modelcontextprotocol)
|
|
|
62
62
|
|
|
63
63
|
* Full catalog of UniFi controller operations – firewall, traffic-routes, port-forwards, QoS, VPN, WLANs, stats, devices, clients **and more**.
|
|
64
64
|
* All mutating tools require `confirm=true` so nothing can change your network by accident.
|
|
65
|
+
* **Workflow automation friendly** – set `UNIFI_AUTO_CONFIRM=true` to skip confirmation prompts (ideal for n8n, Make, Zapier).
|
|
65
66
|
* Works over **stdio** (FastMCP). Optional SSE HTTP endpoint can be enabled via config.
|
|
66
67
|
* **Code execution mode** with tool index, async operations, and TypeScript examples.
|
|
67
68
|
* One-liner launch via the console-script **`unifi-network-mcp`**.
|
|
@@ -520,6 +521,7 @@ The server merges settings from **environment variables**, an optional `.env` fi
|
|
|
520
521
|
| `UNIFI_VERIFY_SSL` | Set to `false` if using self-signed certs |
|
|
521
522
|
| `UNIFI_CONTROLLER_TYPE` | Controller API path type: `auto` (detect), `proxy` (UniFi OS), `direct` (standalone). Default `auto` |
|
|
522
523
|
| `UNIFI_MCP_HTTP_ENABLED` | Set `true` to enable optional HTTP SSE server (default `false`) |
|
|
524
|
+
| `UNIFI_AUTO_CONFIRM` | Set `true` to auto-confirm all mutating operations (skips preview step). Ideal for workflow automation (n8n, Make, Zapier). Default `false` |
|
|
523
525
|
|
|
524
526
|
### Controller Type Detection
|
|
525
527
|
|
|
@@ -46,6 +46,7 @@ A self-hosted [Model Context Protocol](https://github.com/modelcontextprotocol)
|
|
|
46
46
|
|
|
47
47
|
* Full catalog of UniFi controller operations – firewall, traffic-routes, port-forwards, QoS, VPN, WLANs, stats, devices, clients **and more**.
|
|
48
48
|
* All mutating tools require `confirm=true` so nothing can change your network by accident.
|
|
49
|
+
* **Workflow automation friendly** – set `UNIFI_AUTO_CONFIRM=true` to skip confirmation prompts (ideal for n8n, Make, Zapier).
|
|
49
50
|
* Works over **stdio** (FastMCP). Optional SSE HTTP endpoint can be enabled via config.
|
|
50
51
|
* **Code execution mode** with tool index, async operations, and TypeScript examples.
|
|
51
52
|
* One-liner launch via the console-script **`unifi-network-mcp`**.
|
|
@@ -504,6 +505,7 @@ The server merges settings from **environment variables**, an optional `.env` fi
|
|
|
504
505
|
| `UNIFI_VERIFY_SSL` | Set to `false` if using self-signed certs |
|
|
505
506
|
| `UNIFI_CONTROLLER_TYPE` | Controller API path type: `auto` (detect), `proxy` (UniFi OS), `direct` (standalone). Default `auto` |
|
|
506
507
|
| `UNIFI_MCP_HTTP_ENABLED` | Set `true` to enable optional HTTP SSE server (default `false`) |
|
|
508
|
+
| `UNIFI_AUTO_CONFIRM` | Set `true` to auto-confirm all mutating operations (skips preview step). Ideal for workflow automation (n8n, Make, Zapier). Default `false` |
|
|
507
509
|
|
|
508
510
|
### Controller Type Detection
|
|
509
511
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unifi-network-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Unifi Network MCP Server"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -58,3 +58,15 @@ dev-dependencies = [
|
|
|
58
58
|
[project.scripts]
|
|
59
59
|
# CLI entrypoint that launches the UniFi Network MCP server
|
|
60
60
|
unifi-network-mcp = "src.main:main"
|
|
61
|
+
|
|
62
|
+
[tool.ruff]
|
|
63
|
+
line-length = 120
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint]
|
|
66
|
+
select = ["E", "F", "I"]
|
|
67
|
+
ignore = [
|
|
68
|
+
"E501", # Line too long - handled by line-length setting above
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint.isort]
|
|
72
|
+
known-first-party = ["src"]
|
|
@@ -12,13 +12,13 @@ Importing it early guarantees deterministic side‑effects (env + logging) and
|
|
|
12
12
|
exposes a `load_config()` helper that the rest of the codebase can share.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
import importlib.resources
|
|
16
16
|
import logging
|
|
17
17
|
import os
|
|
18
18
|
import sys
|
|
19
|
+
from dataclasses import dataclass
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
from typing import Any
|
|
21
|
-
import importlib.resources
|
|
22
22
|
|
|
23
23
|
from dotenv import load_dotenv
|
|
24
24
|
from omegaconf import OmegaConf
|
|
@@ -118,32 +118,20 @@ def load_config(path_override: str | Path | None = None) -> OmegaConf:
|
|
|
118
118
|
relative_path = Path("config/config.yaml")
|
|
119
119
|
if relative_path.exists() and relative_path.is_file():
|
|
120
120
|
resolved_path = relative_path
|
|
121
|
-
logger.info(
|
|
122
|
-
"Using configuration file from relative path: %s", relative_path
|
|
123
|
-
)
|
|
121
|
+
logger.info("Using configuration file from relative path: %s", relative_path)
|
|
124
122
|
else:
|
|
125
123
|
# 3. Use bundled default config
|
|
126
124
|
try:
|
|
127
125
|
# Use importlib.resources to safely access package data
|
|
128
|
-
config_file_ref = importlib.resources.files("src.config").joinpath(
|
|
129
|
-
"config.yaml"
|
|
130
|
-
)
|
|
126
|
+
config_file_ref = importlib.resources.files("src.config").joinpath("config.yaml")
|
|
131
127
|
if config_file_ref.is_file():
|
|
132
|
-
resolved_path = Path(
|
|
133
|
-
|
|
134
|
-
) # Convert Traversable to Path
|
|
135
|
-
logger.info(
|
|
136
|
-
"Using bundled default configuration: %s", resolved_path
|
|
137
|
-
)
|
|
128
|
+
resolved_path = Path(str(config_file_ref)) # Convert Traversable to Path
|
|
129
|
+
logger.info("Using bundled default configuration: %s", resolved_path)
|
|
138
130
|
else:
|
|
139
|
-
logger.error(
|
|
140
|
-
"Bundled default configuration file could not be accessed (not a file)."
|
|
141
|
-
)
|
|
131
|
+
logger.error("Bundled default configuration file could not be accessed (not a file).")
|
|
142
132
|
raise SystemExit(3) # Exit if bundled config isn't a file
|
|
143
133
|
except (ModuleNotFoundError, FileNotFoundError, Exception) as e:
|
|
144
|
-
logger.error(
|
|
145
|
-
"Could not find or access bundled default configuration: %s", e
|
|
146
|
-
)
|
|
134
|
+
logger.error("Could not find or access bundled default configuration: %s", e)
|
|
147
135
|
raise SystemExit(3) # Exit if bundled config cannot be loaded
|
|
148
136
|
|
|
149
137
|
if resolved_path is None:
|
|
@@ -71,4 +71,20 @@ permissions:
|
|
|
71
71
|
|
|
72
72
|
vpn_servers:
|
|
73
73
|
create: false
|
|
74
|
-
update: true
|
|
74
|
+
update: true
|
|
75
|
+
|
|
76
|
+
events:
|
|
77
|
+
create: false # Events are read-only
|
|
78
|
+
update: true # Allow archiving alarms
|
|
79
|
+
|
|
80
|
+
vouchers:
|
|
81
|
+
create: true # Allow creating guest vouchers
|
|
82
|
+
update: true # Allow revoking vouchers
|
|
83
|
+
|
|
84
|
+
usergroups:
|
|
85
|
+
create: true # Allow creating bandwidth profiles
|
|
86
|
+
update: true # Allow modifying bandwidth limits
|
|
87
|
+
|
|
88
|
+
routes:
|
|
89
|
+
create: false # Static routes can disrupt connectivity
|
|
90
|
+
update: false # Modifying routes requires careful planning
|
|
@@ -78,9 +78,7 @@ class JobStore:
|
|
|
78
78
|
self._jobs[job_id]["status"] = "error"
|
|
79
79
|
self._jobs[job_id]["error"] = str(e)
|
|
80
80
|
self._jobs[job_id]["completed"] = time.time()
|
|
81
|
-
logger.error(
|
|
82
|
-
f"Background job {job_id} failed with error: {e}", exc_info=True
|
|
83
|
-
)
|
|
81
|
+
logger.error(f"Background job {job_id} failed with error: {e}", exc_info=True)
|
|
84
82
|
|
|
85
83
|
# Launch the runner as a background task
|
|
86
84
|
asyncio.create_task(_runner())
|
|
@@ -9,29 +9,28 @@ Responsibilities:
|
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
11
|
import logging
|
|
12
|
-
import traceback
|
|
13
|
-
import sys # Removed uvicorn import
|
|
14
12
|
import os
|
|
13
|
+
import sys # Removed uvicorn import
|
|
14
|
+
import traceback
|
|
15
15
|
|
|
16
16
|
from src.bootstrap import (
|
|
17
|
-
logger,
|
|
18
17
|
UNIFI_TOOL_REGISTRATION_MODE,
|
|
18
|
+
logger,
|
|
19
19
|
) # ensures logging/env setup early
|
|
20
|
+
from src.jobs import get_job_status, start_async_tool
|
|
20
21
|
|
|
21
22
|
# Shared singletons
|
|
22
23
|
from src.runtime import (
|
|
23
|
-
server,
|
|
24
24
|
config,
|
|
25
25
|
connection_manager,
|
|
26
|
+
server,
|
|
26
27
|
)
|
|
27
|
-
|
|
28
|
-
from src.utils.tool_loader import auto_load_tools
|
|
29
|
-
from src.utils.lazy_tool_loader import setup_lazy_loading
|
|
28
|
+
from src.tool_index import register_tool, tool_index_handler
|
|
30
29
|
from src.utils.diagnostics import diagnostics_enabled, wrap_tool
|
|
31
|
-
from src.utils.
|
|
30
|
+
from src.utils.lazy_tool_loader import setup_lazy_loading
|
|
32
31
|
from src.utils.meta_tools import register_meta_tools
|
|
33
|
-
from src.
|
|
34
|
-
from src.
|
|
32
|
+
from src.utils.permissions import parse_permission # noqa: E402
|
|
33
|
+
from src.utils.tool_loader import auto_load_tools
|
|
35
34
|
|
|
36
35
|
_original_tool_decorator = server.tool # keep reference to wrap later
|
|
37
36
|
|
|
@@ -39,11 +38,7 @@ _original_tool_decorator = server.tool # keep reference to wrap later
|
|
|
39
38
|
def permissioned_tool(*d_args, **d_kwargs): # acts like @server.tool
|
|
40
39
|
"""Decorator that only registers the tool if permission allows."""
|
|
41
40
|
|
|
42
|
-
tool_name = (
|
|
43
|
-
d_kwargs.get("name")
|
|
44
|
-
if d_kwargs.get("name")
|
|
45
|
-
else (d_args[0] if d_args else None)
|
|
46
|
-
)
|
|
41
|
+
tool_name = d_kwargs.get("name") if d_kwargs.get("name") else (d_args[0] if d_args else None)
|
|
47
42
|
|
|
48
43
|
category = d_kwargs.pop("permission_category", None)
|
|
49
44
|
action = d_kwargs.pop("permission_action", None)
|
|
@@ -141,9 +136,7 @@ def permissioned_tool(*d_args, **d_kwargs): # acts like @server.tool
|
|
|
141
136
|
if allowed:
|
|
142
137
|
# Permission granted - register with MCP server
|
|
143
138
|
wrapped = (
|
|
144
|
-
wrap_tool(func, tool_name or getattr(func, "__name__", "<tool>"))
|
|
145
|
-
if diagnostics_enabled()
|
|
146
|
-
else func
|
|
139
|
+
wrap_tool(func, tool_name or getattr(func, "__name__", "<tool>")) if diagnostics_enabled() else func
|
|
147
140
|
)
|
|
148
141
|
return _original_tool_decorator(*d_args, **d_kwargs)(wrapped)
|
|
149
142
|
|
|
@@ -168,9 +161,7 @@ try:
|
|
|
168
161
|
|
|
169
162
|
logger.info(f"MCP Python SDK version: {getattr(mcp, '__version__', 'unknown')}")
|
|
170
163
|
logger.info(f"Server methods: {dir(server)}")
|
|
171
|
-
logger.info(
|
|
172
|
-
f"Server tool methods: {[m for m in dir(server) if 'tool' in m.lower()]}"
|
|
173
|
-
)
|
|
164
|
+
logger.info(f"Server tool methods: {[m for m in dir(server) if 'tool' in m.lower()]}")
|
|
174
165
|
except Exception as e:
|
|
175
166
|
logger.error(f"Error inspecting server: {e}")
|
|
176
167
|
|
|
@@ -194,9 +185,7 @@ async def main_async():
|
|
|
194
185
|
try:
|
|
195
186
|
from src.bootstrap import logger as bootstrap_logger_async
|
|
196
187
|
|
|
197
|
-
bootstrap_logger_async.critical(
|
|
198
|
-
"ASYNCHRONOUS main_async() FUNCTION ENTERED - TEST MESSAGE"
|
|
199
|
-
)
|
|
188
|
+
bootstrap_logger_async.critical("ASYNCHRONOUS main_async() FUNCTION ENTERED - TEST MESSAGE")
|
|
200
189
|
except Exception as e:
|
|
201
190
|
print(f"Logging in main_async() failed: {e}", file=sys.stderr) # Fallback
|
|
202
191
|
# ---- END VERY EARLY ASYNC LOG TEST ----
|
|
@@ -220,9 +209,7 @@ async def main_async():
|
|
|
220
209
|
context["exception"].__traceback__,
|
|
221
210
|
)
|
|
222
211
|
)
|
|
223
|
-
logger.error(
|
|
224
|
-
f"Original traceback for global asyncio exception:\n{orig_traceback}"
|
|
225
|
-
)
|
|
212
|
+
logger.error(f"Original traceback for global asyncio exception:\n{orig_traceback}")
|
|
226
213
|
|
|
227
214
|
loop.set_exception_handler(handle_asyncio_exception)
|
|
228
215
|
logger.info("Global asyncio exception handler set.")
|
|
@@ -232,17 +219,13 @@ async def main_async():
|
|
|
232
219
|
log_level = config.server.get("log_level", "INFO").upper()
|
|
233
220
|
# Ensure logging is configured (might be redundant if already set by bootstrap)
|
|
234
221
|
# but this ensures the level is applied if changed post-bootstrap.
|
|
235
|
-
logging.basicConfig(
|
|
236
|
-
level=getattr(logging, log_level, logging.INFO), force=True
|
|
237
|
-
) # Use default format
|
|
222
|
+
logging.basicConfig(level=getattr(logging, log_level, logging.INFO), force=True) # Use default format
|
|
238
223
|
logger.info(f"Log level set to {log_level} in main_async.")
|
|
239
224
|
|
|
240
225
|
# Initialize the global Unifi connection
|
|
241
226
|
logger.info("Initializing global Unifi connection from main_async...")
|
|
242
227
|
if not await connection_manager.initialize():
|
|
243
|
-
logger.error(
|
|
244
|
-
"Failed to connect to Unifi Controller from main_async. Tool functionality may be impaired."
|
|
245
|
-
)
|
|
228
|
+
logger.error("Failed to connect to Unifi Controller from main_async. Tool functionality may be impaired.")
|
|
246
229
|
else:
|
|
247
230
|
logger.info("Global Unifi connection initialized successfully from main_async.")
|
|
248
231
|
|
|
@@ -276,9 +259,7 @@ async def main_async():
|
|
|
276
259
|
# They'll be registered on first use
|
|
277
260
|
from src.utils.lazy_tool_loader import TOOL_MODULE_MAP
|
|
278
261
|
|
|
279
|
-
logger.info(
|
|
280
|
-
f" Lazy loader ready - {len(TOOL_MODULE_MAP)} tools available on-demand"
|
|
281
|
-
)
|
|
262
|
+
logger.info(f" Lazy loader ready - {len(TOOL_MODULE_MAP)} tools available on-demand")
|
|
282
263
|
else: # eager (default)
|
|
283
264
|
logger.info("📚 Tool registration mode: eager")
|
|
284
265
|
logger.info(" All 64+ UniFi tools registered immediately")
|
|
@@ -324,9 +305,7 @@ async def main_async():
|
|
|
324
305
|
server.settings.host = host
|
|
325
306
|
server.settings.port = port
|
|
326
307
|
await server.run_sse_async()
|
|
327
|
-
logger.info(
|
|
328
|
-
"HTTP SSE started via run_sse_async() using server.settings host/port."
|
|
329
|
-
)
|
|
308
|
+
logger.info("HTTP SSE started via run_sse_async() using server.settings host/port.")
|
|
330
309
|
except Exception as http_e:
|
|
331
310
|
logger.error(f"HTTP SSE server failed to start: {http_e}")
|
|
332
311
|
logger.error(traceback.format_exc())
|
|
@@ -353,17 +332,13 @@ def main():
|
|
|
353
332
|
print(f"Logging in main() failed: {e}", file=sys.stderr) # Fallback
|
|
354
333
|
# ---- END VERY EARLY LOG TEST ----
|
|
355
334
|
|
|
356
|
-
logger.debug(
|
|
357
|
-
"Starting main()"
|
|
358
|
-
) # This uses the logger from bootstrap via global scope
|
|
335
|
+
logger.debug("Starting main()") # This uses the logger from bootstrap via global scope
|
|
359
336
|
try:
|
|
360
337
|
asyncio.run(main_async())
|
|
361
338
|
except KeyboardInterrupt:
|
|
362
339
|
logger.info("Server stopped by user (KeyboardInterrupt).")
|
|
363
340
|
except Exception as e:
|
|
364
|
-
logger.exception(
|
|
365
|
-
"Unhandled exception during server run (from asyncio.run): %s", e
|
|
366
|
-
)
|
|
341
|
+
logger.exception("Unhandled exception during server run (from asyncio.run): %s", e)
|
|
367
342
|
finally:
|
|
368
343
|
logger.info("Server process exiting.")
|
|
369
344
|
|
|
@@ -3,6 +3,7 @@ from typing import List, Optional
|
|
|
3
3
|
|
|
4
4
|
from aiounifi.models.api import ApiRequest
|
|
5
5
|
from aiounifi.models.client import Client
|
|
6
|
+
|
|
6
7
|
from .connection_manager import ConnectionManager
|
|
7
8
|
|
|
8
9
|
logger = logging.getLogger("unifi-network-mcp")
|
|
@@ -23,10 +24,7 @@ class ClientManager:
|
|
|
23
24
|
|
|
24
25
|
async def get_clients(self) -> List[Client]:
|
|
25
26
|
"""Get list of currently online clients for the current site."""
|
|
26
|
-
if (
|
|
27
|
-
not await self._connection.ensure_connected()
|
|
28
|
-
or not self._connection.controller
|
|
29
|
-
):
|
|
27
|
+
if not await self._connection.ensure_connected() or not self._connection.controller:
|
|
30
28
|
return []
|
|
31
29
|
|
|
32
30
|
cache_key = f"{CACHE_PREFIX_CLIENTS}_online_{self._connection.site}"
|
|
@@ -45,9 +43,7 @@ class ClientManager:
|
|
|
45
43
|
# Therefore for "online" we fallback to GET /stat/sta.
|
|
46
44
|
if not clients:
|
|
47
45
|
try:
|
|
48
|
-
raw_clients = await self._connection.request(
|
|
49
|
-
ApiRequest(method="get", path="/stat/sta")
|
|
50
|
-
)
|
|
46
|
+
raw_clients = await self._connection.request(ApiRequest(method="get", path="/stat/sta"))
|
|
51
47
|
if isinstance(raw_clients, list) and raw_clients:
|
|
52
48
|
# Cache raw dicts; tool layer handles dict or Client
|
|
53
49
|
self._connection._update_cache(cache_key, raw_clients)
|
|
@@ -62,10 +58,7 @@ class ClientManager:
|
|
|
62
58
|
|
|
63
59
|
async def get_all_clients(self) -> List[Client]:
|
|
64
60
|
"""Get list of all clients (including offline/historical) for the current site."""
|
|
65
|
-
if (
|
|
66
|
-
not await self._connection.ensure_connected()
|
|
67
|
-
or not self._connection.controller
|
|
68
|
-
):
|
|
61
|
+
if not await self._connection.ensure_connected() or not self._connection.controller:
|
|
69
62
|
return []
|
|
70
63
|
|
|
71
64
|
cache_key = f"{CACHE_PREFIX_CLIENTS}_all_{self._connection.site}"
|
|
@@ -75,9 +68,7 @@ class ClientManager:
|
|
|
75
68
|
|
|
76
69
|
try:
|
|
77
70
|
await self._connection.controller.clients_all.update()
|
|
78
|
-
all_clients: List[Client] = list(
|
|
79
|
-
self._connection.controller.clients_all.values()
|
|
80
|
-
)
|
|
71
|
+
all_clients: List[Client] = list(self._connection.controller.clients_all.values())
|
|
81
72
|
# Fallback rationale:
|
|
82
73
|
# - When the clients_all collection is empty, query the canonical
|
|
83
74
|
# UniFi endpoint for all/historical client records.
|
|
@@ -86,9 +77,7 @@ class ClientManager:
|
|
|
86
77
|
# connected. This complements GET /stat/sta used for online-only.
|
|
87
78
|
if not all_clients:
|
|
88
79
|
try:
|
|
89
|
-
raw_all = await self._connection.request(
|
|
90
|
-
ApiRequest(method="get", path="/rest/user")
|
|
91
|
-
)
|
|
80
|
+
raw_all = await self._connection.request(ApiRequest(method="get", path="/rest/user"))
|
|
92
81
|
if isinstance(raw_all, list) and raw_all:
|
|
93
82
|
self._connection._update_cache(cache_key, raw_all)
|
|
94
83
|
return raw_all # type: ignore[return-value]
|
|
@@ -103,13 +92,9 @@ class ClientManager:
|
|
|
103
92
|
async def get_client_details(self, client_mac: str) -> Optional[Client]:
|
|
104
93
|
"""Get detailed information for a specific client by MAC address."""
|
|
105
94
|
all_clients = await self.get_all_clients()
|
|
106
|
-
client: Optional[Client] = next(
|
|
107
|
-
(c for c in all_clients if c.mac == client_mac), None
|
|
108
|
-
)
|
|
95
|
+
client: Optional[Client] = next((c for c in all_clients if c.mac == client_mac), None)
|
|
109
96
|
if not client:
|
|
110
|
-
logger.debug(
|
|
111
|
-
f"Client details for MAC {client_mac} not found in clients_all list."
|
|
112
|
-
)
|
|
97
|
+
logger.debug(f"Client details for MAC {client_mac} not found in clients_all list.")
|
|
113
98
|
return client
|
|
114
99
|
|
|
115
100
|
async def block_client(self, client_mac: str) -> bool:
|
|
@@ -119,14 +104,12 @@ class ClientManager:
|
|
|
119
104
|
api_request = ApiRequest(
|
|
120
105
|
method="post",
|
|
121
106
|
path="/cmd/stamgr",
|
|
122
|
-
|
|
107
|
+
data={"mac": client_mac, "cmd": "block-sta"},
|
|
123
108
|
)
|
|
124
109
|
# Call the updated request method
|
|
125
110
|
await self._connection.request(api_request)
|
|
126
111
|
logger.info(f"Block command sent for client {client_mac}")
|
|
127
|
-
self._connection._invalidate_cache(
|
|
128
|
-
f"{CACHE_PREFIX_CLIENTS}"
|
|
129
|
-
) # Invalidate all client caches
|
|
112
|
+
self._connection._invalidate_cache(f"{CACHE_PREFIX_CLIENTS}") # Invalidate all client caches
|
|
130
113
|
return True
|
|
131
114
|
except Exception as e:
|
|
132
115
|
logger.error(f"Error blocking client {client_mac}: {e}")
|
|
@@ -139,7 +122,7 @@ class ClientManager:
|
|
|
139
122
|
api_request = ApiRequest(
|
|
140
123
|
method="post",
|
|
141
124
|
path="/cmd/stamgr",
|
|
142
|
-
|
|
125
|
+
data={"mac": client_mac, "cmd": "unblock-sta"},
|
|
143
126
|
)
|
|
144
127
|
# Call the updated request method
|
|
145
128
|
await self._connection.request(api_request)
|
|
@@ -155,15 +138,11 @@ class ClientManager:
|
|
|
155
138
|
try:
|
|
156
139
|
client = await self.get_client_details(client_mac)
|
|
157
140
|
if not client or "_id" not in client.raw:
|
|
158
|
-
logger.error(
|
|
159
|
-
f"Cannot rename client {client_mac}: Not found or missing ID."
|
|
160
|
-
)
|
|
141
|
+
logger.error(f"Cannot rename client {client_mac}: Not found or missing ID.")
|
|
161
142
|
return False
|
|
162
143
|
client_id = client.raw["_id"]
|
|
163
144
|
|
|
164
|
-
api_request = ApiRequest(
|
|
165
|
-
method="put", path=f"/upd/user/{client_id}", json={"name": name}
|
|
166
|
-
)
|
|
145
|
+
api_request = ApiRequest(method="put", path=f"/upd/user/{client_id}", data={"name": name})
|
|
167
146
|
await self._connection.request(api_request)
|
|
168
147
|
logger.info(f"Rename command sent for client {client_mac} to '{name}'")
|
|
169
148
|
self._connection._invalidate_cache(f"{CACHE_PREFIX_CLIENTS}")
|
|
@@ -178,7 +157,7 @@ class ClientManager:
|
|
|
178
157
|
api_request = ApiRequest(
|
|
179
158
|
method="post",
|
|
180
159
|
path="/cmd/stamgr",
|
|
181
|
-
|
|
160
|
+
data={"mac": client_mac, "cmd": "kick-sta"},
|
|
182
161
|
)
|
|
183
162
|
await self._connection.request(api_request)
|
|
184
163
|
logger.info(f"Force reconnect (kick) command sent for client {client_mac}")
|
|
@@ -213,12 +192,10 @@ class ClientManager:
|
|
|
213
192
|
payload["bytes"] = bytes_quota
|
|
214
193
|
|
|
215
194
|
# Construct ApiRequest
|
|
216
|
-
api_request = ApiRequest(method="post", path="/cmd/stamgr",
|
|
195
|
+
api_request = ApiRequest(method="post", path="/cmd/stamgr", data=payload)
|
|
217
196
|
# Call the updated request method
|
|
218
197
|
await self._connection.request(api_request)
|
|
219
|
-
logger.info(
|
|
220
|
-
f"Authorize command sent for guest {client_mac} for {minutes} minutes"
|
|
221
|
-
)
|
|
198
|
+
logger.info(f"Authorize command sent for guest {client_mac} for {minutes} minutes")
|
|
222
199
|
self._connection._invalidate_cache(f"{CACHE_PREFIX_CLIENTS}")
|
|
223
200
|
return True
|
|
224
201
|
except Exception as e:
|
|
@@ -231,7 +208,7 @@ class ClientManager:
|
|
|
231
208
|
api_request = ApiRequest(
|
|
232
209
|
method="post",
|
|
233
210
|
path="/cmd/stamgr",
|
|
234
|
-
|
|
211
|
+
data={"mac": client_mac, "cmd": "unauthorize-guest"},
|
|
235
212
|
)
|
|
236
213
|
await self._connection.request(api_request)
|
|
237
214
|
logger.info(f"Unauthorize command sent for guest {client_mac}")
|
|
@@ -240,3 +217,98 @@ class ClientManager:
|
|
|
240
217
|
except Exception as e:
|
|
241
218
|
logger.error(f"Error unauthorizing guest {client_mac}: {e}")
|
|
242
219
|
return False
|
|
220
|
+
|
|
221
|
+
async def set_client_ip_settings(
|
|
222
|
+
self,
|
|
223
|
+
client_mac: str,
|
|
224
|
+
use_fixedip: Optional[bool] = None,
|
|
225
|
+
fixed_ip: Optional[str] = None,
|
|
226
|
+
local_dns_record_enabled: Optional[bool] = None,
|
|
227
|
+
local_dns_record: Optional[str] = None,
|
|
228
|
+
) -> bool:
|
|
229
|
+
"""Set fixed IP and/or local DNS record for a client.
|
|
230
|
+
|
|
231
|
+
Uses the UniFi REST API endpoint PUT /rest/user/{client_id}.
|
|
232
|
+
Local DNS records require UniFi Network 7.2+.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
client_mac: MAC address of the client to update.
|
|
236
|
+
use_fixedip: Enable (True) or disable (False) fixed IP.
|
|
237
|
+
fixed_ip: The fixed IP address to assign (required if use_fixedip=True).
|
|
238
|
+
local_dns_record_enabled: Enable (True) or disable (False) local DNS.
|
|
239
|
+
local_dns_record: The DNS hostname to assign (e.g., "mydevice.local").
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
True if the update was successful, False otherwise.
|
|
243
|
+
"""
|
|
244
|
+
try:
|
|
245
|
+
# Get client to find their internal _id
|
|
246
|
+
client = await self.get_client_details(client_mac)
|
|
247
|
+
if not client:
|
|
248
|
+
logger.error(f"Cannot set IP settings for {client_mac}: Client not found")
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
client_raw = client.raw if hasattr(client, "raw") else client
|
|
252
|
+
if "_id" not in client_raw:
|
|
253
|
+
logger.error(f"Cannot set IP settings for {client_mac}: Missing _id")
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
client_id = client_raw["_id"]
|
|
257
|
+
|
|
258
|
+
# If client is not "noted" (known), mark it first to enable IP config
|
|
259
|
+
if not client_raw.get("noted"):
|
|
260
|
+
logger.info(f"Client {client_mac} not noted, marking as known first")
|
|
261
|
+
note_payload = {"noted": True}
|
|
262
|
+
if not client_raw.get("name") and client_raw.get("hostname"):
|
|
263
|
+
note_payload["name"] = client_raw["hostname"]
|
|
264
|
+
try:
|
|
265
|
+
note_request = ApiRequest(
|
|
266
|
+
method="put",
|
|
267
|
+
path=f"/rest/user/{client_id}",
|
|
268
|
+
data=note_payload,
|
|
269
|
+
)
|
|
270
|
+
await self._connection.request(note_request)
|
|
271
|
+
except Exception as note_err:
|
|
272
|
+
logger.warning(f"Could not mark client as noted: {note_err}")
|
|
273
|
+
|
|
274
|
+
# Build payload with only explicitly provided fields
|
|
275
|
+
payload: dict = {}
|
|
276
|
+
|
|
277
|
+
if use_fixedip is not None:
|
|
278
|
+
payload["use_fixedip"] = use_fixedip
|
|
279
|
+
if use_fixedip and fixed_ip:
|
|
280
|
+
payload["fixed_ip"] = fixed_ip
|
|
281
|
+
elif not use_fixedip:
|
|
282
|
+
payload["fixed_ip"] = ""
|
|
283
|
+
elif fixed_ip is not None:
|
|
284
|
+
# If only fixed_ip provided, assume enabling
|
|
285
|
+
payload["use_fixedip"] = True
|
|
286
|
+
payload["fixed_ip"] = fixed_ip
|
|
287
|
+
|
|
288
|
+
if local_dns_record_enabled is not None:
|
|
289
|
+
payload["local_dns_record_enabled"] = local_dns_record_enabled
|
|
290
|
+
if local_dns_record_enabled and local_dns_record:
|
|
291
|
+
payload["local_dns_record"] = local_dns_record
|
|
292
|
+
elif not local_dns_record_enabled:
|
|
293
|
+
payload["local_dns_record"] = ""
|
|
294
|
+
elif local_dns_record is not None:
|
|
295
|
+
# If only local_dns_record provided, assume enabling
|
|
296
|
+
payload["local_dns_record_enabled"] = True
|
|
297
|
+
payload["local_dns_record"] = local_dns_record
|
|
298
|
+
|
|
299
|
+
if not payload:
|
|
300
|
+
logger.warning(f"No IP settings provided for {client_mac}")
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
api_request = ApiRequest(
|
|
304
|
+
method="put",
|
|
305
|
+
path=f"/rest/user/{client_id}",
|
|
306
|
+
data=payload,
|
|
307
|
+
)
|
|
308
|
+
await self._connection.request(api_request)
|
|
309
|
+
logger.info(f"IP settings updated for client {client_mac}: {payload}")
|
|
310
|
+
self._connection._invalidate_cache(f"{CACHE_PREFIX_CLIENTS}")
|
|
311
|
+
return True
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.error(f"Error setting IP settings for {client_mac}: {e}")
|
|
314
|
+
return False
|