unifi-mcp-rw
Provides tools for interacting with the UniFi Network application on a UniFi console, enabling read access to sites, networks, WiFi broadcasts, firewall policies, clients, APs, and fixed IPs, plus guarded write operations such as toggling mDNS forwarding, creating or updating SSIDs, managing firewall policies and legacy rules, setting or clearing fixed IPs, updating AP radios, and authorizing or unauthorizing guests.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
unifi-mcp-rw
An MCP server that can make a small, fixed set of changes to the UniFi Network
application on a UniFi console such as a UDM (tested on Network 10.6.109,
written against the Network API 10.6.106 published at
developer.ui.com).
It is meant to run next to a read-only UniFi MCP server, listens on
127.0.0.1:18778, and is reached through a reverse proxy such as
tailscale serve. It talks to the console either through the Ubiquiti Site
Manager cloud connector (api.ui.com, the default) or directly to the UDM on
the LAN.
MIT licensed; see LICENSE. Use it at your own risk: it changes a live network.
Why a separate server
A read-only UniFi MCP server stays exactly as it is. It answers questions about the network and cannot change anything, which is the right default for an assistant.
Some changes are worth delegating, though: toggling mDNS forwarding on a network, adding an SSID, setting a fixed IP, allowing one network to reach a couple of devices on another. This server lets an agent make that short list of changes and nothing else. Keeping it separate means it has its own token, its own API key, its own log, and can be stopped or switched to read-only without touching the server everything else depends on.
What makes this safe enough is the guard (lib/guard.cjs). Every request,
reads included, passes through it before a socket is opened. It allows a
fixed list of endpoints and, for each write, inspects the full body that will
be sent: WAN rules and policies can't be weakened, accept rules may only name
private addresses and internal networks, WiFi must use WPA2/WPA3, a network
update may only change the mDNS flag, and so on. The tools enforce the same
rules in their input schemas, but the guard does not rely on them. Writes are
also off unless the config turns them on, and validated attempts, including
dry runs and guard refusals, are recorded in a log.
Three more safeguards sit in front of the guard (see Write safeguards): an allowlist that decides which write tools exist at all, an optional per-call write log at a path you choose, and a WAN-exposure check that refuses anything that would let traffic in from the internet, whatever the caller asks for.
Related MCP server: UniFi MCP Server
Upgrading from 0.2.1
0.3.0 adds the write safeguards. With no new environment variables set, nothing changes for normal use: every write tool is still registered and there is no extra log. Two things can differ:
The WAN-exposure refusals are on by default. With the current tools, every write they refuse was already refused by the guard, so in practice only the error text changes (it now starts with
Refused:instead ofRefused by guard:).A legacy accept rule on a
WAN*ruleset is no longer stopped by the tool's input schema but inside the tool, so the refusal now shows up in the logs.
For an agent that only needs the common changes, set UNIFI_WRITE_TOOLS to the
list in "Minimum allowlist" below, and point UNIFI_WRITE_LOG somewhere under the state directory. The
systemd unit has commented lines for both. Copy it again and run
daemon-reload.
Upgrading from 0.2.0
0.2.1 only tightens the checks on legacy accept rules (see the guard rules
below and the changelog). A few accept rules that 0.2.0 allowed are now
refused, including updates to existing rules of that shape: a source given
only as a MAC address, an address field, group or networkconf type of the
other address family, a network reference with no private subnet in the
rule's family (IPv6 networks with a delegated or public prefix in
particular), and a network reference mixed with addresses on the same side.
Nothing else changes; copy the files and run npm ci as usual.
Upgrading from 0.1.0
0.2.0 starts in read-only mode unless writes are enabled explicitly. The
a config written for 0.1.0 has no allow_writes key, so after the upgrade the
server will list only read tools and refuse every write. When upgrading, add
"allow_writes": trueto ~/.config/unifi-connectivity/config.json, then restart the service and
check the startup line in the journal for mode=read-write. The rest of the
upgrade is the usual copy and npm ci (see Install). Other changes that can
affect existing use:
Legacy accept rules are checked much more strictly (see the guard rules below). A rule that names a public address, or no source at all, is now refused on every LAN and guest ruleset, not only on
*_LOCAL.Creating or updating a legacy accept rule now also reads
rest/firewallgroupandrest/networkconf. Both are classic reads under thefirewallrulecapability, so they count against the connector's rate limit (100 requests per minute per console).The systemd unit changed (
TimeoutStopSec,RestartPreventExitStatus, nonetwork-online.target). Copy it again and rundaemon-reload.
Tools
Read tools (always registered, subject to classic capabilities below):
unifi_rw_get_info, unifi_rw_list_sites, unifi_rw_list_wifi_broadcasts,
unifi_rw_get_wifi_broadcast, unifi_rw_list_networks,
unifi_rw_get_network, unifi_rw_list_firewall_zones,
unifi_rw_list_firewall_policies, unifi_rw_get_firewall_policy,
unifi_rw_list_clients, unifi_rw_get_client, and the classic-backed
unifi_rw_list_legacy_firewall_rules, unifi_rw_list_firewall_groups,
unifi_rw_list_classic_networks, unifi_rw_list_access_points,
unifi_rw_list_fixed_ips. Secrets in responses are redacted.
Write tools, registered only when writes are enabled, and then only those
UNIFI_WRITE_TOOLS names if it is set. All take dry_run (default false).
There is no per-call confirm argument: with write mode enabled, omitting
dry_run sends the write after the guard passes.
Tool | What it sends |
| GET, then PUT the network with only |
| POST a new SSID |
| GET, merge |
| Zone-based policy POST, GET+PUT, GET+PATCH ( |
| Classic |
| Classic |
| Classic |
| Client action POST |
Update tools read the current object first, merge the caller's changes, strip read-only fields and send the whole object. The guard receives the object as it was read and compares the result against it.
Classic /api/s/ tools are grouped into three capabilities: firewallrule
(legacy rules, firewall groups, classic networks), fixed_ip (rest/user)
and radio (stat/device, rest/device). The local backend has all three.
The cloud backend has none unless cloud_allow_classic enables them, and the
client refuses classic paths of a disabled capability even if a tool tried.
Not offered at all: deletes, creating networks, device adopt/restart/upgrade and port actions, port forwards and NAT, WAN, DDNS and UPnP settings, zone changes, policy ordering, ACL rules, DNS policies, VPN, vouchers, controller settings (SSH, remote access), admin accounts, backups, firewall group changes.
Install
Requires Node.js >=22.23.4. CI tests Node.js 22.23.4 and 24.22.1.
On the host that runs it:
mkdir -p ~/.local/share/unifi-mcp-rw ~/.local/state/unifi-mcp-rw
chmod 700 ~/.local/state/unifi-mcp-rw
cp -r http-server-rw.cjs lib scripts package.json package-lock.json ~/.local/share/unifi-mcp-rw/
(cd ~/.local/share/unifi-mcp-rw && npm ci --omit=dev)
umask 077
openssl rand -hex 32 > ~/.config/unifi-connectivity/mcp-rw-token
cat > ~/.config/unifi-connectivity/cloud-rw-key # paste the Site Manager API key, Ctrl-D
chmod 600 ~/.config/unifi-connectivity/mcp-rw-token ~/.config/unifi-connectivity/cloud-rw-keyThe Site Manager key comes from unifi.ui.com, Settings, API Keys. It carries
the rights of the account that created it, so that account needs admin rights
on the console. The console_id is the id of the console in
curl -s -H "X-API-KEY: $(cat ~/.config/unifi-connectivity/cloud-rw-key)" https://api.ui.com/v1/hosts.
Add this server's keys to the shared ~/.config/unifi-connectivity/config.json
(keys it doesn't know are ignored); config.example.json shows the cloud form
and config.local.example.json the local one. Then install the unit:
cp unifi-mcp-rw.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now unifi-mcp-rw.service
journalctl --user -u unifi-mcp-rw -n 20
tailscale serve --bg --set-path /unifi-mcp-rw http://127.0.0.1:18778/unifi-mcp-rwSet MCP_PUBLIC_HOST in the unit to the name the proxy serves (for
tailscale serve, the machine's *.ts.net name); requests for any other Host
are refused.
The last startup line looks like
unifi-mcp-rw: started version=0.3.0 node=v24.22.1 listen=http://127.0.0.1:18778 public_host=mcp-host.example.ts.net backend=cloud mode=read-only allow_writes_source=default classic=off config=/home/you/.config/unifi-connectivity/config.json console_id=0123…In write mode it is preceded by a safeguards line, for example
write safeguards: write_tools=all write_log=/home/you/.local/state/unifi-mcp-rw/write-calls.jsonl wan_exposure=refused.
The server refuses to start (exit status 2, one line naming the problem) if
the config is invalid or a secret file is not a regular file owned by the
user with mode 0600. The unit does not restart on status 2. In user units the
sandboxing directives depend on unprivileged user namespaces; check what
applies with systemd-analyze --user security unifi-mcp-rw.service. If node
is not on the user manager's PATH (nvm, for example), put its absolute path
in ExecStart.
Turning writes on safely
Writes need two things: write mode, and for classic tools on the cloud backend, the capability.
Write mode. Set
"allow_writes": truein config.json. The environment variableUNIFI_ALLOW_WRITESoverrides the config in both directions:trueor1turns writes on,falseor0turns them off, and an empty or unset variable leaves the decision to the config. Any other value is ignored with a startup warning. The systemd unit has a commentedUNIFI_ALLOW_WRITES=falseline for forcing read-only mode without editing the config. In read-only mode no write tool is registered, and the client independently refuses every non-GET request, dry runs included.Classic capabilities (cloud only). Whether classic endpoints work through the cloud connector with a Site Manager key has not been verified. Run the probe first; it only reads and prints shapes and counts, never values:
cd ~/.local/share/unifi-mcp-rw && npm run probe -- --config ~/.config/unifi-connectivity/config.jsonIf
rest/firewallrule,rest/firewallgroupandrest/networkconfanswer with 200, enable only what you need, e.g."cloud_allow_classic": ["firewallrule"].Dry run first. Every write tool accepts
"dry_run": true. The guard runs and the prerequisite reads happen, but the write is not sent; the response shows the exact method, URL and body (API key redacted).Watch the log.
tail -f ~/.local/state/unifi-mcp-rw/writes.logwhile making the first real change. Each line has the tool, its redacted arguments, the canonical endpoint,dry_run, and the outcome: an HTTP status,refused,dry_runorerror.Register only what you need. Set
UNIFI_WRITE_TOOLS(next section) so that write tools you don't use don't exist.
Write safeguards
These are set with environment variables rather than config.json, so they
can live in the systemd unit next to the service they protect and can't be
changed by whatever else edits the shared config. All of them are read once
at startup. A bad value stops the server with exit status 2 and one line
saying what is wrong. They only matter in write mode: in read-only mode
UNIFI_WRITE_TOOLS and UNIFI_WRITE_LOG are still checked, so a typo shows
up early, but are then ignored with a warning.
Variable | Default | Meaning |
| unset: every write tool | Comma-separated write tool names to register. |
| unset: off | Absolute path of a JSON Lines file that gets one line per write tool call. |
| unset: refusals on |
|
| empty | Comma-separated zone UUIDs to treat as the WAN, on top of |
When writes are on, a second startup line shows what is in effect, e.g.
write safeguards: write_tools=all write_log=off wan_exposure=refused.
The write-tool allowlist (UNIFI_WRITE_TOOLS)
Leaving it unset registers every write tool, the same as upstream
unifi-network-mcp does with UNIFI_NETWORK_WRITE_TOOLS and the same as
0.2.x. Once it is set, only the write tools it names are registered. The
others are not hidden or disabled; they don't exist, and a call to one fails
as an unknown tool. Names are separated by commas, and spaces around them
don't matter. Read tools (unifi_rw_list_*, unifi_rw_get_*, including
unifi_rw_list_fixed_ips) are always registered and can't be listed. Naming
one, or a name that isn't a write tool at all, is a startup error that lists
the valid write tool names. Unlike upstream there are no group names; the
list is short enough to spell out.
Classic capabilities still apply on top. A listed classic tool on the cloud
backend only appears if cloud_allow_classic enables its capability.
The names live in one place, lib/tool-names.cjs. Registering a tool that
isn't listed there on the right side (read or write) throws, so the list
can't drift from the code.
Minimum allowlist
What a typical home-network agent needs: SSIDs, the mDNS flag, the Sonos-style legacy LAN rules and fixed IPs. Guest authorization, zone-based policies and AP radio changes are left out.
unifi_rw_create_wifi_broadcast,unifi_rw_update_wifi_broadcast,unifi_rw_update_network,unifi_rw_create_legacy_firewall_rule,unifi_rw_update_legacy_firewall_rule,unifi_rw_set_fixed_ip,unifi_rw_clear_fixed_ipThat is:
unifi_rw_create_wifi_broadcastunifi_rw_update_wifi_broadcastunifi_rw_update_networkunifi_rw_create_legacy_firewall_ruleunifi_rw_update_legacy_firewall_ruleunifi_rw_set_fixed_ipunifi_rw_clear_fixed_ip
With it, unifi_rw_authorize_guest, unifi_rw_unauthorize_guest,
unifi_rw_create_firewall_policy, unifi_rw_update_firewall_policy,
unifi_rw_patch_firewall_policy and unifi_rw_update_ap_radios are not
registered. On the cloud backend the legacy rule and fixed IP tools also
need "cloud_allow_classic": ["firewallrule", "fixed_ip"].
The write log (UNIFI_WRITE_LOG)
One JSON line per write tool call: ts (UTC) and ts_local, tool, args
with secrets redacted, the canonical method and endpoint (no host,
console id or headers), dry_run, outcome (ok, dry_run, refused or
error), status when the controller answered, reason for refusals
(guard or wan_exposure), a short error summary, and duration_ms.
Two things are never logged. Bodies are never logged in either direction:
args is what the caller sent, not the merged object the server built from
the controller's copy, and controller responses don't appear at all. Secrets
are never logged either: any key that looks like a secret (x_passphrase,
password, anything with key, token, secret, and so on) is replaced by
[REDACTED], and the API key and bearer token are scrubbed from the whole
line in case they turn up somewhere unexpected.
The path must be absolute; systemd and MCP hosts don't expand ~. The file
is opened once at startup, created with mode 0600 (missing directories
0700), and appended to. Like upstream, the server refuses to start if the
path is a symlink (it is checked with lstat and opened with
O_NOFOLLOW), isn't a regular file, has other hard links, or is writable
by group or others. A FIFO or other special file is turned away before the
open, and the open is non-blocking, so nothing at that path can hang
startup. A file that others can read but not write is left as it is.
The directory gets checked too, since whoever controls it can swap the file
out. Every directory on the path must be a real directory, not a symlink,
so use the resolved path (on macOS that means /private/var/..., not
/var/...). The directory holding the file must belong to you or root, and
can't be writable by group or others unless it has the sticky bit, like
/tmp.
If a write to the log fails later (disk full, say), that is reported once on stderr and tool calls carry on.
This is separate from writes.log in the state directory, which is always
on and records each controller write request. The write log is opt-in, goes
where you point it, and has one line per tool call, including calls refused
before any request was built. Under the systemd unit
(ProtectHome=read-only), the file has to be inside a ReadWritePaths=
directory; the state directory is the easy choice:
Environment=UNIFI_WRITE_LOG=%h/.local/state/unifi-mcp-rw/write-calls.jsonlWAN-exposure refusals (UNIFI_ALLOW_WAN_EXPOSURE, UNIFI_WAN_ZONE_IDS)
These are on by default and refuse:
a zone-based firewall policy that would ALLOW traffic (any action other than BLOCK or REJECT, a missing one included) from the WAN zone, on create, update, or a patch that touches more than logging;
an edit to the WAN zone, or a zone change that takes networks out of it;
a network update that would move the network into or out of the WAN zone, or turn it into a WAN (
purpose: "wan");a legacy firewall rule on
WAN_IN,WAN_LOCAL,WANv6_INorWANv6_LOCALwhose action isn'tdroporreject. On update, the merged rule is checked, so flipping an existing drop rule to accept is caught too.
Drop and reject rules on those rulesets are still allowed. Some of these writes have no tool here (zone edits, network zone moves); they are checked anyway in case one is added later.
The check runs inside the tool before the guard, before a dry run is
answered, and before anything is sent. A refusal starts with Refused:,
says why, and is logged with reason: "wan_exposure". dry_run doesn't get
around it, and since every tool schema is strict there is no argument that
could. Upstream lets its confirm flag through only after the operator
turns the refusals off; this server has no confirm flag. The only switch is
UNIFI_ALLOW_WAN_EXPOSURE=true in the environment, which logs a warning at
startup. Even then the guard still refuses WAN accept rules and ALLOW
policies from the WAN on its own, so the variable mostly exists to match
upstream and to rule this layer out when debugging.
A zone counts as the WAN when its id is in UNIFI_WAN_ZONE_IDS or
firewall_wan_zone_ids, when it is system-defined and named External (what
the controller creates), or when it is named External, WAN or Internet. Names
can be edited, so the ids are the reliable signal. If the zones can't be
read, or a zone the change depends on isn't among them, the write is
refused. A zone id in both UNIFI_WAN_ZONE_IDS and
firewall_internal_zone_ids is a startup error.
Input-schema rejections happen before tool handlers and are not audited.
Audit logging is best effort: a storage failure emits audit log write failed
in the journal and does not prevent an otherwise allowed write.
Examples
All examples use the MCP endpoint directly with curl. $TOKEN is the bearer
token; ids are placeholders.
TOKEN=$(cat ~/.config/unifi-connectivity/mcp-rw-token)
mcp() {
curl -s https://mcp-host.example.ts.net/unifi-mcp-rw \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' -d "$1"
}mDNS forwarding toggle (dry run)
mcp '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
"name":"unifi_rw_update_network",
"arguments":{"network_id":"3f6c2a10-5b7e-4d2a-9c41-7e8d0a6b1c25",
"changes":{"mdnsForwardingEnabled":true},"dry_run":true}}}'The result text is a JSON object like
{"dry_run": true, "backend": "cloud",
"request": {"method": "PUT",
"url": "https://api.ui.com/v1/connector/consoles/<console_id>/proxy/network/integration/v1/sites/<site>/networks/3f6c2a10-…",
"headers": {"X-API-KEY": "[REDACTED]", "…": "…"},
"body": {"name": "…", "mdnsForwardingEnabled": true, "…": "…"}}}Drop "dry_run":true to send it. The body is the full network as read a
moment earlier, with only mdnsForwardingEnabled changed; the guard refuses
it if anything else differs.
Worked example: Owner devices to the Sonos speakers
The goal: devices on the Owner network (192.168.50.0/24, VLAN 42) control the
Sonos speakers on Guest (VLAN 7, Isolate Network on). The site
runs the legacy firewall (the zone-based firewall is not configured), so this
takes two LAN_IN accept rules: one for new connections from Owner to the
speakers, and one that lets the speakers' replies back to Owner, limited to
established and related traffic. (LAN_IN assumes Guest is a
corporate network with isolation, which is what "Isolate Network" implies. A
network with purpose guest uses the GUEST_* rulesets instead;
unifi_rw_list_classic_networks shows the purpose.) This needs the firewallrule capability,
and classic writes through the connector are unverified, so start with dry
runs.
First find the classic _id of the Owner network. It is not the integration
network id.
mcp '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"unifi_rw_list_classic_networks","arguments":{}}}'Rule 1, Owner to the speakers. The speaker addresses below are placeholders for their fixed IPs:
mcp '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{
"name":"unifi_rw_create_legacy_firewall_rule",
"arguments":{"dry_run":true,"rule":{
"name":"Owner to Sonos","enabled":true,"ruleset":"LAN_IN","action":"accept",
"protocol":"all","logging":false,
"src_networkconf_id":"<Owner _id>","src_networkconf_type":"NETv4",
"dst_address":"192.168.7.20,192.168.7.21"}}}}'Rule 2, replies from the speakers to Owner:
mcp '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{
"name":"unifi_rw_create_legacy_firewall_rule",
"arguments":{"dry_run":true,"rule":{
"name":"Sonos replies to Owner","enabled":true,"ruleset":"LAN_IN","action":"accept",
"protocol":"all","logging":false,
"state_established":true,"state_related":true,"state_new":false,"state_invalid":false,
"src_address":"192.168.7.20,192.168.7.21",
"dst_networkconf_id":"<Owner _id>","dst_networkconf_type":"NETv4"}}}}'Two variations pass the guard as well. The Owner network can be given as
"src_address":"192.168.50.0/24" (and "dst_address" in rule 2) instead of
by id. The speakers can be an address group created in the UniFi UI (this
server cannot create groups); look up its _id with
unifi_rw_list_firewall_groups and use "dst_firewallgroup_ids":["<id>"] in
rule 1 and "src_firewallgroup_ids":["<id>"] in rule 2. Whether the
controller accepts a comma-separated list in dst_address has not been
verified; if it rejects it, use the group.
Before each write the tool reads the firewall groups and classic networks.
The guard then checks that the Owner id refers to an existing, non-WAN
network with a private IPv4 subnet, that every address is private, and that any
group exists and contains only private addresses. The dry-run response shows
the exact request. Without rule_index the controller places the rule; check
in the UniFi UI that both rules come before any LAN_IN drop rule matching
the same traffic. Sonos also relies on multicast discovery and may open
connections from a speaker to the phone running the app; if control still
fails with both rules in place, look at mDNS forwarding and at traffic in that
direction.
Configuration
config.json may be shared with other UniFi tools on the same host; this server
reads only the keys below. Path: ~/.config/unifi-connectivity/config.json,
or $UNIFI_MCP_RW_CONFIG.
Key | Default | Meaning |
|
| Register write tools and allow non-GET requests. Overridden by |
|
|
|
| required (cloud) | Console id from |
|
| Site Manager API key, mode 0600 |
|
|
|
| required (local) | UDM hostname or IPv4, optional |
| system CA | Certificate pin, or CA file, for the UDM (one of them) |
|
| UDM integration API key, mode 0600 |
| the only site | Integration site UUID |
|
| Classic site name |
|
| MCP bearer token, mode 0600, at least 32 characters |
|
| Loopback only |
| empty | Zone UUIDs for zone-based policies (see guard rules) |
Environment: UNIFI_ALLOW_WRITES (see above), UNIFI_MCP_RW_CONFIG (config
path), UNIFI_MCP_RW_STATE_DIR (directory of writes.log, default
~/.local/state/unifi-mcp-rw), and the write safeguards UNIFI_WRITE_TOOLS,
UNIFI_WRITE_LOG, UNIFI_ALLOW_WAN_EXPOSURE and UNIFI_WAN_ZONE_IDS (see
Write safeguards). In cloud mode the host is always
api.ui.com:443 with normal certificate verification; keys such as
cloud_host or cloud_tls_* are refused, and local_* keys are ignored with
a warning. Every value is validated at startup and errors name the key.
Guard rules
The guard sees canonical paths (/integration/v1/… or /api/s/{site}/…).
The backend prefix (/proxy/network, or
/v1/connector/consoles/{id}/proxy/network) is added afterwards and the full
path is checked again, so both backends get the same decisions. In order:
Path and method. Only
GET,POST,PUT,PATCH;DELETEnever. No..,//,.segments, percent-encoding, backslashes,;, control characters or fragments. Query strings only on GET and onlyoffset,limit,filter. A canonical path that already contains a proxy or connector prefix, or names another console app, is refused.Deny list, for clear refusal messages: device commands, firmware, deletes, port forwards,
rest/networkconfwrites, WAN, DDNS, UPnP, admin and settings endpoints, remote access, zone writes, policy ordering, ACL rules, DNS policies, VPN, vouchers, RADIUS.Allowlist. Anchored patterns for exactly the paths the tools use, plus GET-only reads used by the probe. Everything else is refused.
Body scan. Write bodies must be JSON objects. Keys like
ssh*,mgmt*,remote_access*,firmware*,port_forward*,upnp*and__proto__are refused at any depth, as is the literal"[REDACTED]", so a redacted read can never be written back.Per-endpoint checks, described below. Every update needs the object as read just before (
context.existing), with its own id equal to the id in the path; otherwise it is refused.
Legacy firewall rules (classic rest/firewallrule; on this site the live
firewall). ruleset must be one of the 18 classic rulesets (WAN, LAN,
GUEST, each with v6 variants, each IN, OUT, LOCAL) and action one
of accept, drop, reject. Only known rule fields are allowed, each with
a type check; unknown fields are refused, so a rule the controller returns
with an unfamiliar field cannot be edited here. Rules marked attr_no_edit
are never edited.
No accept on any
WAN*ruleset. NewWAN*rules may not setrule_index. An existingWAN*drop or reject rule may only changenameandlogging. A rule never moves out of aWAN*ruleset, andrule_indexnever changes on one.An accept rule on any other ruleset must name its source in the rule's own address family:
src_networkconf_id, an address group insrc_firewallgroup_ids(address-groupfor IPv4 rulesets,ipv6-address-groupfor v6), orsrc_address(src_address_ipv6for v6). A port group alone does not count, and neither doessrc_mac_address: a MAC may be added to one of the above as an extra restriction, but it says nothing about where the traffic comes from.Everything an accept rule uses to select addresses must be of the ruleset's family. IPv4 rulesets (
LAN_IN,GUEST_OUT, ...) takesrc_address/dst_address,address-groupgroups, and networkconf typesNETv4/ADDRv4. v6 rulesets (LANv6_IN, ...) takesrc_address_ipv6/dst_address_ipv6,ipv6-address-groupgroups, andNETv6/ADDRv6. Port groups fit both. Anything of the other family is refused, not ignored.In the classic rule model
*_networkconf_typequalifies*_networkconf_id:NETis the network's subnet,ADDRthe gateway's address on it. A non-empty id needs a type. An empty id with a type of the right family is how the controller stores rules without a network reference; that is allowed and does not count as a source. A network reference cannot be combined with an address or address group on the same side, since the UniFi UI never produces that and it is unclear which one the controller would match.Every address in an accept rule (
src_address,dst_address, and the_ipv6fields; single addresses, CIDRs,a-branges and comma-separated lists of them) must lie entirely inside one private block: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, 169.254.0.0/16, fc00::/7, fe80::/10. 100.64.0.0/10 is shared address space; it is included because Tailscale assigns tailnet addresses from it. Public addresses,0.0.0.0/0,::/0and anything malformed are refused.Accept rules may not negate:
!in an address or port, andprotocol_match_excepted: true, are refused. Ports must be 1-65535.Firewall groups and networks are resolved from data the tool reads right before the write. Every referenced group must exist; address groups may contain only private addresses, port groups only valid ports, other group types are refused. A referenced network must exist, have purpose
corporate,guest,vlan-only,remote-user-vpnorsite-vpn(sowan,vpn-clientand unknown purposes are refused), and a private subnet in the family the networkconf type selects. ForNETv4/ADDRv4that isip_subnet. ForNETv6/ADDRv6the network must haveipv6_interface_type: "static"and anipv6_subnet(andipv6_static_subnet, if present) inside fc00::/7 or fe80::/10. A missing or malformed subnet is refused, as is a network whose IPv6 prefix is delegated from the WAN (ipv6_interface_type: "pd"): the prefix is not in the network record and is normally public. For IPv6 accept rules on such networks, name ULA addresses insrc_address_ipv6/dst_address_ipv6or use anipv6-address-groupinstead. If the lookup failed, the rule is refused and the reason is included.The destination may be left out. On
*_INthat means any destination the traffic from that internal source is routed to; on*_OUTit means any destination on that side. Both are common (a broad reply rule, for example) and are allowed deliberately. If a destination is given it is checked like the source.Drop and reject rules may name public or negated addresses; blocking them is harmless.
Zone-based policies. The zone object carries no reliable type and names
can be edited, so zones are classified by id in config
(firewall_internal_zone_ids, firewall_wan_zone_ids, from
unifi_rw_list_firewall_zones). ALLOW policies need both ends in the internal
list and present on the controller; without that list every ALLOW is refused.
When firewall_internal_zone_ids is missing, null or empty, startup warns:
firewall_internal_zone_ids not set: zone-based ALLOW policies will be refused, and every BLOCK/REJECT policy is treated as WAN-side (cosmetic changes only).
New BLOCK/REJECT policies are still allowed when the zones can be fetched;
the cosmetic-only restriction applies to updates of existing deny policies.
A BLOCK or REJECT policy whose source is not a configured internal zone may
only change name, description and loggingEnabled. A zone named
External, WAN or Internet is always treated as WAN. PATCH may only set
loggingEnabled.
WiFi broadcasts. Security must be WPA2 or WPA3 (personal or enterprise);
open and OWE networks are refused. A new SSID must bind to a named network
({"type": "SPECIFIC", "networkId": …}), not the default network, and may not
have hotspot or captive-portal settings. Updates may not change the type, the
network binding, client isolation or portal settings.
Networks. Only mdnsForwardingEnabled may differ from the current
object. Everything else in the network DTO affects addressing, reachability or
exposure.
AP radios, fixed IPs, guests. Radio updates send only radio_table, only
for radios the AP has, and only channel, ht, tx_power_mode, tx_power,
min_rssi_enabled, min_rssi within fixed ranges. Fixed-IP writes may only
touch mac, use_fixedip, fixed_ip, network_id, name, note. Guest
actions are limited to authorize and unauthorize, with the limits from the
10.6.106 spec.
Refusals come back as tool errors starting with Refused by guard: and are
logged with status refused. Upstream error responses are reduced to
{"status", "code", "message"} with a fixed message per status, and the API
key and bearer token are scrubbed from everything sent to the caller or
written to the log.
HTTP layer
Checks run in this order: Host, Origin, path, method, bearer token,
Content-Type, size, JSON. Allowed hosts are localhost:18778,
127.0.0.1:18778, and the proxy's name from MCP_PUBLIC_HOST (with or without
:443), if set; an Origin header, if present, must be https:// plus that name
or a loopback URL.
Paths are /mcp and /unifi-mcp-rw. Only POST with application/json, at
most 256 KiB. Clients must send Accept: application/json, text/event-stream
(an SDK requirement; otherwise 406). The MCP transport is created per request
(stateless, JSON responses). Internal errors return a fixed Internal error; details go only
to the journal.
On SIGTERM the server stops accepting connections, refuses new writes, and gives in-flight requests up to 35 seconds (just above the 30-second cloud timeout) before closing what is left. The unit allows 45 seconds.
Verification status
Verified on 2026-10-18 on a UDM through the cloud
connector (backend cloud, cloud_allow_classic: false, no zone ids
configured):
GET /integration/v1/sitesreturned 200 with one site,Default.unifi_rw_update_networktoggledmdnsForwardingEnabledon one network in both directions, HTTP 200 each time. The readback showed only that field changed; the full-object PUT did not reset server-managed fields.unifi_rw_create_wifi_broadcastcreated an SSID through the connector at 6:26 PM ET.So integration API writes work through the connector, and the Site Manager key has write rights on this console.
The site has the zone-based firewall not configured (firewall/policies and
firewall/zones return 400), so the legacy rules are the live firewall, and
the integration API has no endpoints for them.
Verified on 2026-11-04 with 0.3.0 deployed (backend cloud,
cloud_allow_classic for firewallrule and fixed_ip, write log on):
Classic reads through the connector:
unifi_rw_list_legacy_firewall_rules(10 rules),unifi_rw_list_fixed_ips(3) andunifi_rw_list_firewall_groups(none) returned without error.A dry-run
unifi_rw_create_legacy_firewall_rulewithWAN_IN/acceptwas refused by the WAN-exposure check before any request, and the write log recorded it asoutcome: refused,reason: wan_exposure.
Not verified: any classic /api/s/ write, through the connector or with a
UDM API key on the LAN, and any classic read with a LAN API key. That covers
writes to legacy rules, firewall groups, classic networks, fixed IPs and AP
radios. The other integration writes (WiFi update,
zone-based policies, guest actions) follow the 10.6.106 OpenAPI spec at
developer.ui.com but have not been exercised live.
Limitations
The guard trusts the reads it is given. The current object, the zone list, firewall groups and networks are read by the tool just before the write; a compromised controller, or another admin changing something between the read and the write, could make that data stale or false. Zone classification comes from config only.
"Private" is not the same as "internal". If the ISP uses CGNAT, the WAN side of the gateway may itself be in 100.64.0.0/10; with double NAT it may be in 192.168.0.0/16. A LAN or guest accept rule still cannot open inbound WAN traffic, because that is filtered by
WAN_IN/WAN_LOCAL, where accept is refused and existing drop rules are frozen, and port forwards are denied.LAN and guest rules can still change reachability between internal networks. That is their purpose, and it is why each one should go through a dry run and a look at the UniFi UI.
An existing accept rule that breaks the new rules (made in the UI with a public address, say) cannot be edited here, only turned into drop or reject. Use the UniFi UI for it.
Firewall groups can only be created and edited in the UniFi UI.
Networks without an
ip_subnet(vlan-only, andsite-vpnnetworks, which list their remote subnets elsewhere) cannot be referenced by id in an accept rule; name their addresses instead. The same goes for IPv6 networks with a delegated or public prefix, which covers most IPv6 setups on a residential connection. The IPv6 checks are based on the classic networkconf fields as documented by the community, not on a live record from this site; a controller that stores the IPv6 subnet under another field name will see such references refused, not allowed.If the controller stores a networkconf type of the other family on rules that reference no network (
NETv4on a v6 rule, say), accept rules of that kind made in the UI cannot be edited here.AP radio updates: if the controller replaces rather than merges
radio_tableentries, per-radio settings this tool does not send (antenna gain, sensitivity) may revert to defaults. Try it on one AP first.An existing zone-based ALLOW policy from a WAN or non-internal zone cannot remain ALLOW on a full update, even when disabling it. It can be changed to BLOCK/REJECT; PATCH may change only its logging flag.
The WAN-exposure check relies on the same reads as the guard (zone list, current object), so the first point applies to it too. It knows the WAN zone by id only if you configure it; otherwise it goes by the zone's name and origin.
UNIFI_WRITE_TOOLSdecides what is registered. It is not a second check in the client: a tool that is registered can send whatever the guard allows.There is no client-side rate limiter. An update makes two or three requests; creating a legacy accept rule makes three, updating one four.
Development
npm test # node --test test/*.test.cjs
npm run check # node --check on every source fileTests use node:test only and contact nothing but localhost: the guard
matrix, replayed through local and cloud clients to show identical
decisions; the HTTP gate; a real server process on an ephemeral loopback
port (startup line, read-only and read-write modes, config errors, SIGTERM
drain, the write safeguards); config and secret-file validation; dry runs
through the full MCP stack; the write-tool allowlist, write log and
WAN-exposure refusals; the Sonos rule pair and its refusals; the probe; and TLS behaviour
against local TLS servers with throwaway certificates (skipped if openssl
is missing). The test glob is expanded by the shell. CI uses the Node.js
versions listed under Install.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
This server cannot be deployed
Maintenance
Related MCP Connectors
Supervised API-write gateway for AI agents with policy, human approval and execution receipts.
Manage ngrok from your agent: endpoints, domains, and traffic policy.
Deterministic allow/require_approval/deny verdicts for agent actions, before they happen.
Security gateway for AI agents: policy, approval, and audited execution, no secrets shared.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables comprehensive management of UniFi Network infrastructure through 24 tools for monitoring and controlling devices, clients, wireless networks, security, and guest access. Supports network administration tasks like device restarts, client blocking, WLAN configuration, and backup creation.13 npmMIT
- AlicenseCqualityAmaintenanceEnables AI agents to manage UniFi network infrastructure via the Model Context Protocol, supporting device management, network configuration, security, and QoS through local or cloud APIs.7145 npm1,176 PyPI351Apache 2.0

Axonity Flow MCP Serverofficial
AlicenseAqualityAmaintenanceEnables AI agents to author and manage workflows, agents, tools, skills, policies, and reference docs in an Axonity tenant via the public REST API, with guardrails preventing direct publishing and secret exposure.100399 npmMIT- AlicenseNot gradedqualityBmaintenanceEnables AI agents and applications to interact with UniFi network infrastructure through the UniFi Network Controller API, supporting device, client, network, firewall, QoS, backup, multi-site, and topology management.45 npmApache 2.0