Skip to main content
Glama

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 of Refused 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": true

to ~/.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/firewallgroup and rest/networkconf. Both are classic reads under the firewallrule capability, so they count against the connector's rate limit (100 requests per minute per console).

  • The systemd unit changed (TimeoutStopSec, RestartPreventExitStatus, no network-online.target). Copy it again and run daemon-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

unifi_rw_update_network

GET, then PUT the network with only mdnsForwardingEnabled changed

unifi_rw_create_wifi_broadcast

POST a new SSID

unifi_rw_update_wifi_broadcast

GET, merge changes, PUT the full SSID (passphrase kept unless changed)

unifi_rw_create_firewall_policy / update_ / patch_

Zone-based policy POST, GET+PUT, GET+PATCH (loggingEnabled only)

unifi_rw_create_legacy_firewall_rule / update_

Classic rest/firewallrule POST, or GET+merge+PUT

unifi_rw_set_fixed_ip / clear_fixed_ip

Classic rest/user PUT or POST

unifi_rw_update_ap_radios

Classic rest/device/{_id} PUT with radio_table only

unifi_rw_authorize_guest / unauthorize_guest

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-key

The 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-rw

Set 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.

  1. Write mode. Set "allow_writes": true in config.json. The environment variable UNIFI_ALLOW_WRITES overrides the config in both directions: true or 1 turns writes on, false or 0 turns 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 commented UNIFI_ALLOW_WRITES=false line 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.

  2. 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.json

    If rest/firewallrule, rest/firewallgroup and rest/networkconf answer with 200, enable only what you need, e.g. "cloud_allow_classic": ["firewallrule"].

  3. 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).

  4. Watch the log. tail -f ~/.local/state/unifi-mcp-rw/writes.log while 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_run or error.

  5. 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

UNIFI_WRITE_TOOLS

unset: every write tool

Comma-separated write tool names to register. "" or none registers none.

UNIFI_WRITE_LOG

unset: off

Absolute path of a JSON Lines file that gets one line per write tool call.

UNIFI_ALLOW_WAN_EXPOSURE

unset: refusals on

true (any case) turns the WAN-exposure refusals off. Nothing else does.

UNIFI_WAN_ZONE_IDS

empty

Comma-separated zone UUIDs to treat as the WAN, on top of firewall_wan_zone_ids.

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_ip

That is:

  • 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_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.jsonl

WAN-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_IN or WANv6_LOCAL whose action isn't drop or reject. 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

allow_writes

false

Register write tools and allow non-GET requests. Overridden by UNIFI_ALLOW_WRITES.

backend

"cloud"

"cloud" (Site Manager connector) or "local" (UDM on the LAN)

console_id

required (cloud)

Console id from GET https://api.ui.com/v1/hosts

cloud_rw_key_file

~/.config/unifi-connectivity/cloud-rw-key

Site Manager API key, mode 0600

cloud_allow_classic

false

false, true (all) or a subset of ["firewallrule", "fixed_ip", "radio"]

local_host

required (local)

UDM hostname or IPv4, optional :port

local_tls_cert_sha256 / local_tls_ca_file

system CA

Certificate pin, or CA file, for the UDM (one of them)

local_rw_key_file

~/.config/unifi-connectivity/local-rw-key

UDM integration API key, mode 0600

site_id (local_site_id)

the only site

Integration site UUID

site_name (local_site_name)

"default"

Classic site name

rw_token_file

~/.config/unifi-connectivity/mcp-rw-token

MCP bearer token, mode 0600, at least 32 characters

rw_listen_host / rw_listen_port

127.0.0.1 / 18778

Loopback only

firewall_internal_zone_ids / firewall_wan_zone_ids

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:

  1. Path and method. Only GET, POST, PUT, PATCH; DELETE never. No .., //, . segments, percent-encoding, backslashes, ;, control characters or fragments. Query strings only on GET and only offset, limit, filter. A canonical path that already contains a proxy or connector prefix, or names another console app, is refused.

  2. Deny list, for clear refusal messages: device commands, firmware, deletes, port forwards, rest/networkconf writes, WAN, DDNS, UPnP, admin and settings endpoints, remote access, zone writes, policy ordering, ACL rules, DNS policies, VPN, vouchers, RADIUS.

  3. Allowlist. Anchored patterns for exactly the paths the tools use, plus GET-only reads used by the probe. Everything else is refused.

  4. 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.

  5. 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. New WAN* rules may not set rule_index. An existing WAN* drop or reject rule may only change name and logging. A rule never moves out of a WAN* ruleset, and rule_index never 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 in src_firewallgroup_ids (address-group for IPv4 rulesets, ipv6-address-group for v6), or src_address (src_address_ipv6 for v6). A port group alone does not count, and neither does src_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, ...) take src_address/dst_address, address-group groups, and networkconf types NETv4/ADDRv4. v6 rulesets (LANv6_IN, ...) take src_address_ipv6/dst_address_ipv6, ipv6-address-group groups, and NETv6/ADDRv6. Port groups fit both. Anything of the other family is refused, not ignored.

  • In the classic rule model *_networkconf_type qualifies *_networkconf_id: NET is the network's subnet, ADDR the 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 _ipv6 fields; single addresses, CIDRs, a-b ranges 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, ::/0 and anything malformed are refused.

  • Accept rules may not negate: ! in an address or port, and protocol_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-vpn or site-vpn (so wan, vpn-client and unknown purposes are refused), and a private subnet in the family the networkconf type selects. For NETv4/ADDRv4 that is ip_subnet. For NETv6/ADDRv6 the network must have ipv6_interface_type: "static" and an ipv6_subnet (and ipv6_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 in src_address_ipv6/dst_address_ipv6 or use an ipv6-address-group instead. If the lookup failed, the rule is refused and the reason is included.

  • The destination may be left out. On *_IN that means any destination the traffic from that internal source is routed to; on *_OUT it 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/sites returned 200 with one site, Default.

  • unifi_rw_update_network toggled mdnsForwardingEnabled on 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_broadcast created 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) and unifi_rw_list_firewall_groups (none) returned without error.

  • A dry-run unifi_rw_create_legacy_firewall_rule with WAN_IN / accept was refused by the WAN-exposure check before any request, and the write log recorded it as outcome: 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, and site-vpn networks, 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 (NETv4 on 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_table entries, 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_TOOLS decides 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 file

Tests 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…

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables 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 npm
      MIT
    • A
      license
      C
      quality
      A
      maintenance
      Enables 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.
      71
      45 npm
      1,176 PyPI
      351
      Apache 2.0
    • A
      license
      A
      quality
      A
      maintenance
      Enables 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.
      100
      399 npm
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables 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 npm
      Apache 2.0