omada-mcp 1.2.2__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Faleh Alsubaie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,569 @@
1
+ Metadata-Version: 2.4
2
+ Name: omada-mcp
3
+ Version: 1.2.2
4
+ Summary: Model Context Protocol server for TP-Link Omada SDN controllers (Web API v2, zero dependencies)
5
+ Author: Faleh Alsubaie
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/falehsub/omada-mcp
8
+ Project-URL: Repository, https://github.com/falehsub/omada-mcp
9
+ Project-URL: Issues, https://github.com/falehsub/omada-mcp/issues
10
+ Project-URL: Changelog, https://github.com/falehsub/omada-mcp/blob/main/CHANGELOG.md
11
+ Keywords: mcp,model-context-protocol,omada,tplink,tp-link,sdn,network,eap,er605,oc200
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Operating System :: MacOS
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Topic :: System :: Networking
26
+ Classifier: Topic :: System :: Systems Administration
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.10
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Dynamic: license-file
32
+
33
+ # omada-mcp
34
+
35
+ **A Model Context Protocol server for TP-Link Omada SDN controllers — zero dependencies, read-only by default, and honest about every failure.**
36
+
37
+ [![CI](https://github.com/falehsub/omada-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/falehsub/omada-mcp/actions/workflows/ci.yml)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
39
+ [![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue.svg)](pyproject.toml)
40
+
41
+ Your Omada controller already knows which AP is overloaded, which client is
42
+ sitting on the guest VLAN, and which switch dropped a port. `omada-mcp` hands
43
+ that to Claude, Cursor, Hermes or any other MCP host — as 33 read-only tools
44
+ that return clean JSON (plus 46 optional write tools behind an explicit allowlist,
45
+ plus `--watch` and `--export-metrics` monitoring modes),
46
+ with a self-test that tells you exactly what to fix when something is wrong.
47
+
48
+ ```
49
+ uvx omada-mcp --doctor
50
+ ```
51
+
52
+ ```
53
+ omada-mcp doctor v1.2.1 on Linux-6.8 (Python 3.12.7)
54
+ ------------------------------------------------------------------
55
+ PASS env /home/you/.config/omada-mcp/.env | url=https://192.0.2.10:8043 user=omada-readonly password=set (24 chars)
56
+ PASS tls verified against /home/you/.config/omada-mcp/controller-ca.pem (serverName=Omada) | reachable, omadacId=0123456789abcdef01234567, controllerVer=6.2.10.17, family=software-6.x
57
+ PASS login token accepted for omada-readonly (role=Viewer, scope=all sites, cookie=TPOMADA_SESSIONID), Csrf-Token round-trip OK
58
+ PASS site Home (89abcdef0123456789abcdef) site 1 of 1; matched OMADA_SITE_NAME=Home
59
+ PASS tools list_devices -> 7 device(s); 33 read-only tools registered (0 write tool(s) enabled), 23 client(s)
60
+ PASS write disabled (OMADA_WRITE_TOOLS is empty; read-only tools only)
61
+ ------------------------------------------------------------------
62
+ 6/6 checks passed
63
+ Ready: `omada-mcp` will serve 33 read-only tools on stdio.
64
+ ```
65
+
66
+ Add `--json` (`omada-mcp --doctor --json`) for machine-readable output validated against [`schemas/doctor.schema.json`](schemas/doctor.schema.json). JSON output redacts local `.env` and `OMADA_CA_BUNDLE` filesystem paths to origin labels (`OMADA_ENV_FILE`, `cwd`, `appdata`, `xdg`, `environment`) so reports are safe to paste into issues.
67
+
68
+ ---
69
+
70
+ ## Why this exists
71
+
72
+ Omada exposes two unrelated APIs and the documentation blurs them:
73
+
74
+ | | **Web API v2** (`/{omadacId}/api/v2/...`) | **Open API** (`/openapi/v1/...`) |
75
+ |---|---|---|
76
+ | Auth | username + password (`Csrf-Token` + `TPOMADA_SESSIONID`) | OAuth2 client-credentials |
77
+ | Setup on controller | none — it is the API the web UI calls | "Platform Integration" must be configured |
78
+ | Account roles | Viewer is enough | Client ID/secret per integration |
79
+ | Works on OC200/OC300 | yes | firmware dependent |
80
+
81
+ `omada-mcp` speaks the **Web API**. That means: create a local Viewer account,
82
+ put three values in a dotenv file, and you are done. No OAuth app, no client
83
+ secret rotation, no per-firmware integration screen.
84
+
85
+ Three properties make it safe to hand to an LLM:
86
+
87
+ * **Read-only tools only.** Every tool is annotated `readOnlyHint: true`, and
88
+ `OMADA_READ_ONLY=true` fails closed: a call to anything not declared read-only
89
+ is refused with `-32003` *before* lookup, so a future write tool cannot leak
90
+ past the switch.
91
+ * **No secret echo.** The password never appears in output (only `set (N chars)`),
92
+ the session token is never logged, and WLAN pre-shared keys are redacted —
93
+ `list_wlans` reports `hasPresharedKey: true` instead.
94
+ * **Errors that carry the fix.** Controller error codes are translated
95
+ (`-1505`, `-1600`, `-33726`, `-41011`, `-44116`, …) and every message ends with
96
+ the action to take.
97
+
98
+ ## Install
99
+
100
+ ```bash
101
+ # from git (works today)
102
+ uvx --from git+https://github.com/falehsub/omada-mcp omada-mcp --doctor
103
+
104
+ # from PyPI (once v1.2.1 is published there -- the name `omada-mcp` is reserved-free)
105
+ uvx omada-mcp --doctor
106
+
107
+ # or install the command (once published to PyPI)
108
+ uv tool install omada-mcp # or: pipx install omada-mcp
109
+ pip install omada-mcp # plain pip
110
+
111
+ # today, without PyPI:
112
+ git clone https://github.com/falehsub/omada-mcp && cd omada-mcp
113
+ uv tool install . # installs the `omada-mcp` command from the checkout
114
+ ```
115
+
116
+ Requires Python 3.10+ and nothing else. There are **no runtime dependencies** —
117
+ `urllib`, `ssl`, `json` and `argparse` are the whole stack, which keeps the
118
+ supply chain at zero.
119
+
120
+ ## Configure
121
+
122
+ ```bash
123
+ mkdir -p ~/.config/omada-mcp
124
+ cp .env.example ~/.config/omada-mcp/.env
125
+ chmod 600 ~/.config/omada-mcp/.env
126
+ $EDITOR ~/.config/omada-mcp/.env
127
+ ```
128
+
129
+ The minimum is three lines:
130
+
131
+ ```dotenv
132
+ OMADA_BASE_URL=https://192.0.2.10:8043
133
+ OMADA_USERNAME=omada-readonly
134
+ OMADA_PASSWORD=...
135
+ ```
136
+
137
+ ### Where configuration is read from
138
+
139
+ Highest priority first — the first hit wins per variable, and the process
140
+ environment always overrides a file:
141
+
142
+ 1. **process environment** (`export OMADA_BASE_URL=...`)
143
+ 2. **`$OMADA_ENV_FILE`** — an explicit path (missing file = hard error, so a typo
144
+ cannot silently fall through)
145
+ 3. **`./.env`** — relative to the working directory (handy for per-project setups)
146
+ 4. **`%APPDATA%\omada-mcp\.env`** — Windows only, where a Windows user expects a per-user config
147
+ 5. **`~/.config/omada-mcp/.env`** (or `$XDG_CONFIG_HOME/omada-mcp/.env`)
148
+
149
+ ### Windows
150
+
151
+ Everything is supported natively — no WSL, no MSYS. The console script installs as
152
+ `omada-mcp.exe`, and `uvx` / `uv tool install` behave exactly as on POSIX.
153
+
154
+ ```powershell
155
+ New-Item -ItemType Directory -Force "$env:APPDATA\omada-mcp" | Out-Null
156
+ Copy-Item .env.example "$env:APPDATA\omada-mcp\.env"
157
+ notepad "$env:APPDATA\omada-mcp\.env"
158
+ ```
159
+
160
+ Windows-specific behaviour, handled for you:
161
+
162
+ * **Config location.** `%APPDATA%\omada-mcp\.env` is picked up before
163
+ `~/.config/omada-mcp/.env`, so the usual Windows location just works.
164
+ * **Encoding.** Stdio is forced to UTF-8. The console code page (cp1252, cp1256,
165
+ cp437 …) cannot encode an Arabic or CJK SSID, and the failure mode is a
166
+ `UnicodeEncodeError` in the middle of a session — this never reaches you.
167
+ * **Line endings.** Frames are written with a single `\n`; text mode would
168
+ otherwise emit `\r\n` and strict clients can mis-frame. Incoming `\r\n`
169
+ frames are accepted too.
170
+ * **Proxies.** System/WinINET proxies are ignored by default
171
+ (`OMADA_USE_SYSTEM_PROXY=true` to opt in).
172
+ * **File permissions.** API keys live in your user profile; `--doctor` skips the
173
+ POSIX `chmod` advice on Windows rather than handing you a command that does
174
+ nothing. To restrict the file to your account:
175
+ `icacls "%APPDATA%\omada-mcp\.env" /inheritance:r /grant:r "%USERNAME%:F"`.
176
+
177
+ `get_device`/`get_client` accept a MAC in any format (`AA-BB-CC-DD-EE-FF`,
178
+ `aabbccddeeff`, `aa:bb:cc:dd:ee:ff`) as well as a hostname or IP, so PowerShell
179
+ output pastes straight in.
180
+
181
+ ### All variables
182
+
183
+ | Variable | Default | Notes |
184
+ |---|---|---|
185
+ | `OMADA_BASE_URL` | — **required** | `https://host:8043`. Scheme-less values get `https://…:8043`; `http://` defaults to port 8088. Must not embed credentials. |
186
+ | `OMADA_USERNAME` | — **required** | Local controller account. `Viewer` is sufficient for reads; use a **dedicated Admin account** for writes (every login evicts an existing session sharing that username). |
187
+ | `OMADA_PASSWORD` | — **required** | Never printed, never logged. |
188
+ | `OMADA_SITE_NAME` | first site | Display name, e.g. `Home`. |
189
+ | `OMADA_SITE_ID` | first site | Hex id **or** name — names are resolved, because the controller only accepts the hex id in URLs (that mismatch is exactly what `-1505` reports). |
190
+ | `OMADA_CA_BUNDLE` | system store | PEM bundle with the controller's CA. |
191
+ | `OMADA_VERIFY_SSL` | `true` | `false` disables chain + hostname checks. Not for production. |
192
+ | `OMADA_STRICT_X509` | `true` | `false` clears Python 3.13+'s `VERIFY_X509_STRICT` while keeping chain and hostname checks — the fix for "self-signed certificate" errors on 3.13/3.14. |
193
+ | `OMADA_TLS_SERVER_NAME` | — | Optional TLS `server_hostname` override (e.g. `Omada`) when connecting by IP against the controller's built-in cert (`SAN=DNS:Omada`) so `OMADA_CA_BUNDLE` still verifies the chain and SAN. |
194
+ | `OMADA_READ_ONLY` | `true` | Default `true` fails closed and refuses every non-read-only tool (`-32003`) before argument validation; set `false` to enable allowlisted writes. |
195
+ | `OMADA_WRITE_TOOLS` | empty | Comma-separated exact write tool names to enable (wildcards and `all` are rejected). Both `OMADA_READ_ONLY=false` and this allowlist are required to expose or invoke any write tool. |
196
+ | `OMADA_SNAPSHOT_DIR` | platform state dir | Override directory for pre-write JSON snapshots and `audit.jsonl` (POSIX `0700`/`0600`). Defaults to `$XDG_STATE_HOME/omada-mcp/snapshots` (`%LOCALAPPDATA%\omada-mcp\snapshots` on Windows). |
197
+ | `OMADA_MANAGEMENT_CIDRS` | empty | Comma-separated IPv4 hosts/CIDRs (in addition to controller host IP) protected from lockouts during VLAN isolation, DHCP pool, or deny-ACL writes. |
198
+ | `OMADA_USE_SYSTEM_PROXY` | `false` | System/registry proxies are ignored by default. On Windows, `urllib` inherits the WinINET proxy, which makes a one-hop controller look unreachable; set `true` only when a proxy is genuinely required. |
199
+ | `OMADA_TIMEOUT_SECONDS` | `30` | Per-request timeout. |
200
+ | `OMADA_MIN_INTERVAL_SECONDS` | `0.25` | Minimum spacing between controller calls; raise to `0.8` on large sites. |
201
+ | `OMADA_WRITE_INTERVAL_SECONDS` | `1.0` | Minimum serialized spacing between write requests (writes never auto-retry `-1600`). |
202
+ | `OMADA_SCHEMA_MODE` | `slim` | `tools/list` schema detail: `slim` (default, ~48% smaller payload while keeping `inputSchema` constraints and MCP `2025-06-18` `outputSchema` invariants intact) or `full`. |
203
+ | `OMADA_TOOL_CATEGORIES` | all | Optional comma-separated tool categories (`core,diagnostics,switch,wifi,routing,security,rollback`) to expose in `tools/list`. |
204
+ | `OMADA_COMPACT_OUTPUT` | `true` | Emit single-line compact JSON in `tools/call` `content[0].text` (`structuredContent` stays a typed JSON object). Set `false` for 2-space indented JSON. |
205
+
206
+ `omada-mcp --show-config` prints the resolved values with the password masked;
207
+ `omada-mcp --doctor` validates them.
208
+
209
+ ## Connect an MCP host
210
+
211
+ The server is plain stdio: a host runs `uvx omada-mcp`, then speaks JSON-RPC on
212
+ stdin/stdout. The `OMADA_ENV_FILE` environment variable lets several hosts share
213
+ one configuration file.
214
+
215
+ ### Hermes Agent
216
+
217
+ ```bash
218
+ hermes mcp add omada --command uvx --args omada-mcp \
219
+ --env OMADA_ENV_FILE=$HOME/.config/omada-mcp/.env
220
+ ```
221
+
222
+ Or in `~/.hermes/config.yaml`:
223
+
224
+ ```yaml
225
+ mcp_servers:
226
+ omada:
227
+ command: uvx
228
+ args: ["omada-mcp"]
229
+ env:
230
+ OMADA_ENV_FILE: /home/you/.config/omada-mcp/.env
231
+ ```
232
+
233
+ ### Claude Desktop
234
+
235
+ `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or
236
+ `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
237
+
238
+ ```json
239
+ {
240
+ "mcpServers": {
241
+ "omada": {
242
+ "command": "uvx",
243
+ "args": ["omada-mcp"],
244
+ "env": {
245
+ "OMADA_ENV_FILE": "/home/you/.config/omada-mcp/.env"
246
+ }
247
+ }
248
+ }
249
+ }
250
+ ```
251
+
252
+ A copy lives at [`examples/claude_desktop_config.json`](examples/claude_desktop_config.json).
253
+
254
+ ### Claude Code
255
+
256
+ ```bash
257
+ claude mcp add omada --env OMADA_ENV_FILE=$HOME/.config/omada-mcp/.env -- uvx omada-mcp
258
+ claude mcp list
259
+ ```
260
+
261
+ ### Cursor
262
+
263
+ `.cursor/mcp.json` in the project, or the global `~/.cursor/mcp.json`:
264
+
265
+ ```json
266
+ {
267
+ "mcpServers": {
268
+ "omada": {
269
+ "command": "uvx",
270
+ "args": ["omada-mcp"],
271
+ "env": { "OMADA_ENV_FILE": "/home/you/.config/omada-mcp/.env" }
272
+ }
273
+ }
274
+ }
275
+ ```
276
+
277
+ ### Anything else
278
+
279
+ Any host that can spawn a process and speak newline-delimited JSON-RPC 2.0 works:
280
+
281
+ ```json
282
+ {
283
+ "mcpServers": {
284
+ "omada": {
285
+ "command": "uvx",
286
+ "args": ["omada-mcp"],
287
+ "env": { "OMADA_ENV_FILE": "/home/you/.config/omada-mcp/.env" }
288
+ }
289
+ }
290
+ }
291
+ ```
292
+
293
+ From a git checkout instead of PyPI:
294
+
295
+ ```bash
296
+ uvx --from git+https://github.com/falehsub/omada-mcp omada-mcp --doctor
297
+ ```
298
+
299
+ ## Tools
300
+
301
+ Thirty-three read-only tools (~11.3k tokens in `tools/list`) are always registered;
302
+ 46 optional write tools stay hidden and disabled until both `OMADA_READ_ONLY=false`
303
+ and `OMADA_WRITE_TOOLS` allowlist them explicitly. `list_clients` windowing is bounded
304
+ by `limit` (default 500, max 5000) and de-duplicated by MAC.
305
+
306
+ ### Read-only tools (always available)
307
+
308
+ | Tool | Arguments | Returns |
309
+ |---|---|---|
310
+ | `get_site_info` | — | Site name/id, WAN/LAN/WLAN state, device and client counts, controller version |
311
+ | `list_devices` | — | MAC, name, type, model, firmware, IP, uptime, client count, status, `needUpgrade` |
312
+ | `list_clients` | `limit?` | MAC, name, hostname, IP, wireless, guest, SSID, AP, network, VLAN, rx/tx, duration, last seen (UTC ISO-8601) |
313
+ | `list_wifi_clients` | `limit?` | Same projection, wireless only |
314
+ | `list_wired_clients` | `limit?` | Same projection, wired only |
315
+ | `search_clients` | `query?`, `active?`, `wireless?`, `guest?`, `limit?` | Filtered client search (`clients` for Admin; automatic `insight/clients` fallback on Viewer `-1007`/`-41011`) |
316
+ | `get_device` | `mac` (or name) | One device |
317
+ | `get_client` | `mac` (or hostname/IP) | One client |
318
+ | `list_wlans` | — | SSID, band, VLAN, security, guest, `hasPresharedKey` (expands v6.x WLAN groups; key never returned) |
319
+ | `list_wlan_groups` | — | Wireless WLAN group IDs/names and member SSID summaries (secrets stripped) |
320
+ | `list_lan_networks` | — | Merged `setting/lan/networks` + `/interface` view (VLAN, subnet, DHCP `ipRangePool`, `leasetime`, `isolation`, `accessControlRule`) |
321
+ | `list_port_profiles` | — | Switch port profiles (`setting/lan/profiles`) with native/tagged VLANs, PoE mode, and `prohibitModify` flag |
322
+ | `list_dhcp_reservations` | `vlan?` | Offline/static DHCP reservations from `setting/service/dhcp` |
323
+ | `list_acls` | `aclType?` | Gateway (`?type=0`), switch (`?type=1`), or EAP (`?type=2`) ACL rules and advisory `aclDisable` flag |
324
+ | `list_ip_groups` | `groupType?` | IP/port/profile groups (`setting/profiles/groups`) with CIDRs / entries |
325
+ | `list_port_forwards` | — | Port-forwarding NAT rules (`setting/transmission/portForwardings`) |
326
+ | `list_static_routes` | — | Static/interface routes (`setting/transmission/staticRoutings`) |
327
+ | `list_policy_routes` | — | Policy routing rules (`setting/routing/policy`), degrading cleanly when no gateway is present |
328
+ | `list_lan_dns_rules` | — | Local LAN DNS records (`setting/dns/local`), degrading cleanly on older firmware |
329
+ | `get_network_services` | — | UPnP, DNS proxy, mDNS rules & built-in profiles, DDNS, and WAN port state |
330
+ | `list_logs` | `category?`, `hours?`, `level?`, `limit?` | Controller `alerts`, `events`, or `audit` logs within time window |
331
+ | `get_switch_ports` | `mac` | Per-switch port link/speed/duplex, PoE budget/power, LAG membership, uplink & controller-port flags, RX/TX errors, and cable tests |
332
+ | `get_ap_radios` | `mac` | Per-AP `2g`/`5g`/`5g2`/`6g` channel (resolved from `channelInfo` + `actualChannel`), width, TX power, utilization %, interference, and retry/drop rates |
333
+ | `get_rf_scan` | `mac` | Latest RF scan status and per-band interference summary for an AP |
334
+ | `get_wan_status` | `maskWanIp?`, `includeSpeedtest?` | Live WAN link/internet state, failover/load-balance mode, gateway/DNS, latency/loss, and latest speedtest |
335
+ | `list_vpn_tunnels` | — | WireGuard, OpenVPN, IPsec, and L2TP tunnel & peer state (all private keys, PSKs, and PEM blocks redacted) |
336
+ | `get_topology` | — | Compact uplink tree (Gateway -> Switch[port] -> AP/Switch) using `topology-v2` with v5 device-list fallback |
337
+ | `get_security_status` | — | IPS/IDS status and recent threat/WIDS alerts with graceful missing-hardware warnings |
338
+ | `diagnose_client` | `macOrQuery`, `hours?` | Composite client troubleshooter correlating client RSSI/SNR, parent AP radio or switch port counters, and recent logs |
339
+ | `diagnose_wan` | `maskWanIp?`, `hours?` | Composite WAN troubleshooter correlating link/failover, latency/loss, VPN state, and WAN log events |
340
+ | `network_audit` | — | Composite one-call health audit: pending firmware, offline devices, PoE budget >= 85%, uplink speed mismatches, AP retry spikes, WAN loss/failover, unexpected VLAN clients, unisolated VLANs, DHCP pool utilization, and stable finding codes |
341
+ | `explain_error` | `code`, `endpoint?` | Structured decoder for Omada `errorCode` values (`-1600`, `-33717`, `-33726`, `-1505`, `-1001`, `-30109`, `-41011`, `-44116`, …) with ambiguity notes and fixes |
342
+ | `get_config_snapshot` | `objects?` | Read-only snapshot merging `networks`, `acls`, `groups`, `wlans`, `devices`, and optional `clients`, `dhcpReservations`, `portForwards`, `staticRoutes`, `mdnsRules`, `services` |
343
+
344
+ ### Opt-in write tools (`OMADA_READ_ONLY=false` + `OMADA_WRITE_TOOLS`)
345
+
346
+ Every write tool defaults `dryRun=true`, captures a pre-mutation JSON snapshot under `OMADA_SNAPSHOT_DIR`, requires `confirm="<tool>:<target>"` when `dryRun=false`, verifies state on read-back, and never auto-retries on `-1600`.
347
+
348
+ | Category | Tools | Behaviour & Safety |
349
+ |---|---|---|
350
+ | Snapshot rollback | `rollback_from_snapshot` | Loads snapshot strictly inside `OMADA_SNAPSHOT_DIR`, previews drift on `dryRun=true`, and restores pre-mutation config on `dryRun=false` |
351
+ | Switch ports & profiles | `update_switch_port`, `cycle_poe_port`, `upsert_port_profile` | Port enable/PoE mode/profile updates and PoE power cycling; refuses stranding uplink, controller-hosting, or LAG member ports unless `allowUplinkMutation=true` |
352
+ | AP radios, RF scan & WLAN groups | `update_ap_radio`, `start_rf_scan`, `assign_ap_wlan_group` | Preserves sibling band configs during `config/radios` PUT; pre-validates channel & width against `availableChannels` |
353
+ | Live client controls & fixed IP | `set_client_fixed_ip`, `release_client_fixed_ip`, `set_client_blocked`, `reconnect_client`, `update_client`, `set_client_lock_to_ap`, `set_client_rate_limit` | Full-object stripped client PATCH (`netId` preserved, 20 volatile keys stripped), Lock-to-AP (`clientLockToApSetting`), and rate limits |
354
+ | Offline DHCP reservations | `upsert_dhcp_reservation`, `delete_dhcp_reservation` | Create/update/delete reservations in `setting/service/dhcp` with subnet & IP-overlap validation |
355
+ | LAN & VLAN management | `create_lan_network`, `update_lan_network`, `delete_lan_network`, `set_vlan_dhcp_pools`, `set_vlan_isolation` | Two-endpoint LAN RMW, normalizes `lanNetworkIpv6Config` + `dhcpGuard`/`dhcpv6Guard`, uses `priDns`/`sndDns`, checks `ipRangePool`, refuses management/primary VLAN deletion |
356
+ | IP groups & ACLs | `create_ip_group`, `replace_ip_group`, `delete_ip_group`, `create_gateway_acl`, `update_gateway_acl`, `set_acl_rule_status`, `delete_gateway_acl`, `create_switch_acl`, `create_eap_acl` | Aligned CIDR packing (`<=16`, `-33726`), typed `gateway`/`switch`/`eap` ACL toggle/update/delete + auto 6-step swap (`-33717`), and management-host anti-stranding |
357
+ | Wireless SSIDs | `create_ssid`, `update_ssid`, `delete_ssid` | Manage SSIDs under `setting/wlans/{groupId}/ssids`; PSK/secret keys scrubbed recursively from diffs, outputs, and snapshots |
358
+ | Routing, DNS, NAT & services | `upsert_port_forward`, `delete_port_forward`, `upsert_static_route`, `delete_static_route`, `upsert_policy_route`, `delete_policy_route`, `upsert_lan_dns_rule`, `delete_lan_dns_rule`, `upsert_mdns_rule`, `delete_mdns_rule`, `set_upnp` | NAT port forwards, static & policy routing, local LAN DNS records, mDNS repeater rules, and UPnP toggle |
359
+ | Device operations | `update_device`, `reboot_device` | Rename / LED / locate / reboot EAPs, switches, and gateways (`destructive=True` on reboot; firmware upgrade remains unshipped) |
360
+
361
+ `omada-mcp --list-tools` prints active descriptors without touching the network
362
+ (add `--all` to inspect all 79 tools including non-allowlisted writes).
363
+
364
+ Every descriptor in `tools/list` declares MCP annotations and an MCP `2025-06-18`
365
+ `outputSchema`, so hosts can auto-approve reads and validate structured results:
366
+
367
+ ```json
368
+ {
369
+ "name": "list_devices",
370
+ "title": "List Omada devices",
371
+ "outputSchema": {
372
+ "type": "object",
373
+ "properties": {
374
+ "items": { "type": "array" },
375
+ "count": { "type": "integer", "minimum": 0 }
376
+ },
377
+ "required": ["items", "count"]
378
+ },
379
+ "annotations": {
380
+ "title": "List Omada devices",
381
+ "readOnlyHint": true,
382
+ "destructiveHint": false,
383
+ "idempotentHint": true,
384
+ "openWorldHint": true
385
+ }
386
+ }
387
+ ```
388
+
389
+ ### Output shape (`content` vs `structuredContent`)
390
+
391
+ * **List tools** (`list_devices`, `list_clients`, `list_wifi_clients`,
392
+ `list_wired_clients`, `search_clients`, `list_wlans`, `list_wlan_groups`,
393
+ `list_lan_networks`, `list_port_profiles`, `list_dhcp_reservations`, `list_acls`,
394
+ `list_ip_groups`, `list_port_forwards`, `list_static_routes`,
395
+ `list_policy_routes`, `list_lan_dns_rules`, `list_vpn_tunnels`, `list_logs`):
396
+ `content[0].text` is serialized as a
397
+ **bare JSON array** (`[{...}, ...]`), while `structuredContent` is an object
398
+ matching `outputSchema`: `{"items": [...], "count": <int>}`. Callers parsing
399
+ `content[0].text` (for example migrating from wrapper objects like
400
+ `{"devices": [...]}`) receive a JSON list directly, whereas MCP `2025-06-18`
401
+ hosts read `structuredContent.items` and `structuredContent.count`.
402
+ * **Object tools** (`get_device`, `get_client`, `get_site_info`,
403
+ `get_network_services`, `get_switch_ports`, `get_ap_radios`, `get_rf_scan`,
404
+ `get_wan_status`, `get_topology`, `get_security_status`, `diagnose_client`,
405
+ `diagnose_wan`, `network_audit`, `explain_error`, `get_config_snapshot`,
406
+ and write envelopes): both `content[0].text` and `structuredContent` contain
407
+ the same JSON object matching `outputSchema`.
408
+
409
+ `omada-mcp --list-tools` prints active descriptors without touching the network;
410
+ `omada-mcp --list-tools --all` prints all 79 descriptors (add `--compact` for single-line JSON).
411
+ Direct controller connections reuse persistent HTTP/1.1 keep-alive TLS sockets (`UrllibTransport`),
412
+ and composite troubleshooters (`network_audit`, `diagnose_client`, `diagnose_wan`,
413
+ `get_config_snapshot`, `--watch` / `--export-metrics`) deduplicate repeated site-wide GETs within
414
+ a single invocation via an ephemeral per-call cache (`OmadaClient.scoped_cache()`).
415
+
416
+ ## Watch mode, Prometheus metrics & Docker
417
+
418
+ Both monitoring modes reuse **one persistent authenticated `OmadaClient` session**
419
+ and enforce `>= 0.8 s` pacing so they never evict shared sessions:
420
+
421
+ ```bash
422
+ # Stream JSON telemetry snapshot + event diffs (wan_failover, ap_retry_spike, weak_client_rssi, dhcp_pool_utilization)
423
+ omada-mcp --watch --interval 30
424
+
425
+ # One-shot Prometheus text exposition
426
+ omada-mcp --export-metrics
427
+
428
+ # Persistent /metrics HTTP endpoint (cached with >= 5 s scrape TTL + /healthz)
429
+ omada-mcp --export-metrics --metrics-port 9108 --metrics-host 127.0.0.1
430
+ ```
431
+
432
+ Build and run the non-root container (`OMADA_READ_ONLY=true` by default):
433
+
434
+ ```bash
435
+ docker build -t omada-mcp .
436
+ docker run --rm -i --env-file ~/.config/omada-mcp/.env omada-mcp --doctor
437
+ docker run --rm -p 9108:9108 --env-file ~/.config/omada-mcp/.env omada-mcp --export-metrics --metrics-port 9108 --metrics-host 0.0.0.0
438
+ ```
439
+
440
+ ## Safety model
441
+
442
+ | Control | Behaviour |
443
+ |---|---|
444
+ | `OMADA_READ_ONLY=true` | Refuses any tool not declared read-only (`-32003`), before argument validation or transport. |
445
+ | `OMADA_WRITE_TOOLS` allowlist | Defaults empty. Wildcards and `all` are rejected; write tools remain hidden and return `-32003` unless explicitly named. |
446
+ | Two-phase apply (`dryRun` / `confirm`) | Every write defaults `dryRun=true` (zero writes; returns diff + `confirmToken`). Applying requires boolean `dryRun=false` and exact `confirm="<tool>:<target-id>"`. |
447
+ | Snapshot before & verify after | Saves pre-write JSON snapshot + `audit.jsonl` record (`0700`/`0600` on POSIX), re-reads state afterwards, and fails on silent controller no-ops or collateral field drift. |
448
+ | Anti-stranding guards | Refuses VLAN isolation, DHCP pool overlaps, or deny ACL rules that isolate/conflict with controller IP, gateway, device IPs, or `OMADA_MANAGEMENT_CIDRS`. |
449
+ | Credential handling | Password only ever rendered as `set (N chars)`; tokens never logged; stdout is reserved for JSON-RPC, diagnostics go to stderr. |
450
+ | Secret redaction | `list_wlans` and `get_config_snapshot` omit `securityKey` / `pskSetting`; only `hasPresharedKey` is exposed. |
451
+ | Session recovery | A 200 response containing the web UI shell triggers **one** re-login: reads replay once, writes re-read live state first and never auto-retry `-1600`. |
452
+ | Pacing | Reads spaced by `OMADA_MIN_INTERVAL_SECONDS`; writes serialized at `OMADA_WRITE_INTERVAL_SECONDS` (default 1.0 s). |
453
+ | Dotenv hygiene | `--doctor` warns when the dotenv file is readable by group/other, and flags Viewer accounts when write tools are enabled. |
454
+
455
+ ## Troubleshooting
456
+
457
+ Run `omada-mcp --doctor` first — it names the failing phase and the fix. The
458
+ table below maps the symptoms it cannot see.
459
+
460
+ | Symptom | Cause | Fix |
461
+ |---|---|---|
462
+ | `-1505` on a site call | Site **name** used where the hex site id is required | Set `OMADA_SITE_NAME` (or `OMADA_SITE_ID`) and let the server resolve it |
463
+ | `-1600` everywhere | Wrong path shape (`GET /networks/{id}`, missing `/{type}` on group DELETE, no PATCH on groups) **or** burst rate-limiting | Reads retry once after 0.8 s; writes never auto-retry `-1600` |
464
+ | `-33717` on group delete | Group is referenced by one or more ACL rules | Use `replace_ip_group`, which performs the 6-step temp-group ACL swap automatically |
465
+ | `-33726` on an IP group write | The group holds at most **16** entries | Store ranges as network-aligned CIDRs (packed automatically by `create_ip_group`/`replace_ip_group`) |
466
+ | `-1001` "bean validation" | A mandatory field missing from a PATCH body — for LAN networks it is `lanNetworkIpv6Config` (`proto: "none"`, `enable: 0`) | Send the whole object (handled automatically by LAN write tools) |
467
+ | `-1007` "no permission" | Current user role lacks write permission (e.g. Viewer account on Omada 6.2.x) | Use a dedicated Admin account for writes; keep Viewer for read-only mode |
468
+ | `-33598` "network isolation" | No adopted gateway/switch on the site supports Network Isolation | Adopt an isolation-capable gateway/switch or use gateway ACLs instead |
469
+ | `-33601` "invalid destination type" | `destinationType: 0` (`network`) used on a `LAN->WAN` gateway ACL | Use an IP group (e.g. `IPGroup_Any`) or port group on `LAN->WAN` rules |
470
+ | `-41011` | Client does not exist, MAC belongs to a managed device, **or** Viewer role on `/clients/*` | Use `insight/clients` or `list_devices` to inspect; use a dedicated Admin account for writes |
471
+ | `-44116` | You are on the Open API without Platform Integration | Use this server (Web API) instead |
472
+ | HTTP 200 with HTML in response | The controller's **server-side** session died (cookie is still valid for 15 days) — another login for the same account, or a restart | Automatic: the client re-authenticates once and replays |
473
+ | `CERTIFICATE_VERIFY_FAILED` | Self-signed controller cert, IP/SAN mismatch, or Python 3.13+ strict X509 | Set `OMADA_CA_BUNDLE` to the controller CA, `OMADA_STRICT_X509=false`, and `OMADA_TLS_SERVER_NAME=Omada` when connecting by IP; `OMADA_VERIFY_SSL=false` only as a last resort |
474
+ | `cannot reach …` | Wrong port or unroutable network | Controller web UI is `https://host:8043` (or `http://host:8088`) |
475
+ | Host starts but every call fails | No credentials at server start | Expected: `initialize`/`tools/list` never touch the controller; run `--doctor` |
476
+ | Windows: timeouts although the controller pings | WinINET proxy inherited by `urllib` | Proxies are off by default; if you need one, `OMADA_USE_SYSTEM_PROXY=true` |
477
+ | Windows: `omada-mcp` not found after `pip install --user` | `%APPDATA%\Python\Python3x\Scripts` not on `PATH` | Use `uvx`/`uv tool install`, or add the Scripts directory to `PATH` |
478
+
479
+ ## Compatibility
480
+
481
+ See [`docs/compatibility.md`](docs/compatibility.md) for the full compatibility table.
482
+
483
+ * **Software controller v6.x** — read-only path (`--doctor --json` and all 33
484
+ read-only tools) verified live on v6.2.10.17 (`TPOMADA_SESSIONID`), and 20 baseline
485
+ read-only tools plus controller-native write CRUD (`create/update/delete_lan_network`
486
+ with explicit `ipaddrStart`/`ipaddrEnd` pools, `set_vlan_dhcp_pools`,
487
+ `upsert/delete_dhcp_reservation`, `create/replace/delete_ip_group`,
488
+ `create/update/set_acl_rule_status/delete_gateway_acl` including the 6-step swap,
489
+ `create_switch_acl`/`create_eap_acl` with typed toggle/update/delete lifecycle,
490
+ `create/update/delete_ssid` with PSK redaction, `upsert/delete_port_forward`,
491
+ `upsert/delete_static_route`, `upsert/delete_mdns_rule`, `set_upnp` dry-run + live
492
+ disable + `-33001` gateway check, `-33598` on hardwareless isolation, Viewer
493
+ `-1007 -> insight/clients` fallback, and `-41011`/not-found probes) verified live
494
+ against an isolated Docker Omada Controller v6.2.14.11; client fixed-IP lease-delayed
495
+ behavior (`set_client_fixed_ip`/`release_client_fixed_ip`), client actions, and device
496
+ controls (`update_device`, `reboot_device`) are covered by the stateful mock controller.
497
+ * **Software controller v5.x** — expected (v5.13.30.8 core auth/list shapes
498
+ supported; awaiting a `--doctor --json` capture via the issue template).
499
+ * **OC200 / OC300 hardware & `TPEAP_SESSIONID` builds** — expected (`--doctor`
500
+ detects both `TPOMADA_SESSIONID` and `TPEAP_SESSIONID`, handles legacy
501
+ `result.omadacId` login shapes, and reports `hardware-5.x` / `hardware-6.x`).
502
+ Run `omada-mcp --doctor --json` and submit a
503
+ [Controller Compatibility Report](https://github.com/falehsub/omada-mcp/issues/new?template=controller-report.yml).
504
+
505
+ ## Development
506
+
507
+ ```bash
508
+ git clone https://github.com/falehsub/omada-mcp && cd omada-mcp
509
+ python -m unittest discover -s tests -v # 148 tests, no network, no deps
510
+ python -m omada_mcp.cli --help # after: pip install -e .
511
+ uvx --from . omada-mcp --doctor # what an external user does
512
+
513
+ # uv caches the build of a local *path* and does not rebuild when the source
514
+ # changes (`--refresh` does not help). Force a fresh environment while iterating:
515
+ UV_CACHE_DIR=$(mktemp -d) uvx --from . omada-mcp --doctor
516
+ ```
517
+
518
+ Layout:
519
+
520
+ ```
521
+ src/omada_mcp/config.py dotenv discovery, validation, base-URL normalisation, write allowlist
522
+ src/omada_mcp/client.py Web API v2 client: auth, pagination, write gate, snapshots/audit, errors
523
+ src/omada_mcp/_helpers.py shared v5/v6 diagnostics, secret redaction, topology & capability helpers
524
+ src/omada_mcp/tools.py 33 read-only tools + 46 write descriptors, projections, outputSchema
525
+ src/omada_mcp/write_tools.py read-modify-write handlers, CIDR packer, anti-stranding, rollback, 6-step swap
526
+ src/omada_mcp/monitor.py single-session --watch JSON diff streamer & --export-metrics Prometheus exporter
527
+ src/omada_mcp/server.py JSON-RPC stdio server, fail-closed gates, strict arg/bool checks
528
+ src/omada_mcp/doctor.py 6-phase self-test (env, tls, login, site, tools, write) with --json
529
+ Dockerfile minimal non-root container (stdio default, --watch, --export-metrics)
530
+ schemas/doctor.schema.json JSON Schema for --doctor --json
531
+ docs/compatibility.md controller family and cookie compatibility matrix
532
+ docs/write-support.md write-support contract, verified endpoints, and controller traps
533
+ tests/mock_controller.py stateful Web API v2 mock controller with fault injection
534
+ tests/test_bridge.py read-only bridge, M1 diagnostics, M3 watch/metrics & protocol tests
535
+ tests/test_write_support.py write-gate, RMW flows, anti-stranding, rollback & fault-injection tests
536
+ skills/omada-controller/ agent skill: field notes for the Omada API itself
537
+ ```
538
+
539
+ The API notes in [`skills/omada-controller/SKILL.md`](skills/omada-controller/SKILL.md)
540
+ document the parts of the controller that are not in any public reference:
541
+ session expiry semantics, CIDR packing limits, dual-pool DHCP bodies, and the
542
+ endpoints that are Admin-only.
543
+
544
+ ## Write support
545
+
546
+ Read-only mode remains the default (`OMADA_WRITE_TOOLS` defaults empty and write
547
+ tools are hidden). Opt-in writes follow the contract in
548
+ [`docs/write-support.md`](docs/write-support.md) — explicit `OMADA_WRITE_TOOLS`
549
+ allowlist, `dryRun=true` default, `confirm="<tool>:<target-id>"`, pre-write snapshots +
550
+ `audit.jsonl`, whole-object verification, and anti-stranding checks. Use a **dedicated
551
+ Admin local account** separate from your read bridge and web UI to avoid session
552
+ eviction. Firmware upgrades remain unshipped pending a maintenance-window policy.
553
+
554
+ ## Roadmap
555
+
556
+ Read-only inspection, composite diagnostics (`get_switch_ports`, `get_ap_radios`,
557
+ `get_wan_status`, `get_topology`, `diagnose_client`, `diagnose_wan`), `--doctor --json`,
558
+ `network_audit`, the 79-tool control surface (`rollback_from_snapshot` + anti-stranding
559
+ switch/AP/routing/DNS/ACL/client controls), `--watch`/`--export-metrics`, and non-root
560
+ Docker packaging are implemented on `main`. Next items in [ROADMAP.md](ROADMAP.md)
561
+ include MCP Registry `server.json` / PyPI publishing and multi-version live controller captures.
562
+
563
+ ## License
564
+
565
+ MIT — see [LICENSE](LICENSE).
566
+
567
+ Not affiliated with TP-Link. Omada is a trademark of TP-Link Technologies.
568
+ This server calls an undocumented internal API; a controller firmware update can
569
+ change it, which is exactly why `--doctor` exists.