Server data from the Official MCP Registry
MCP server that runs on an OpenWrt router: deny-by-default policy, audit log, rollback-armed changes
About
MCP server that runs on an OpenWrt router: deny-by-default policy, audit log, rollback-armed changes
Security Report
Valid MCP server (1 strong, 1 medium validity signals). No known CVEs in dependencies. Imported from the Official MCP Registry.
4 files analyzed · No issues found
Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.
Permissions Required
This plugin requests these system permissions. Most are normal for its category.
Documentation
View on GitHubFrom the project's GitHub README.
openwrt-mcp -- an MCP server for OpenWrt 24.10 and 25.12
An MCP server that runs on an OpenWrt router, so Claude Code (or any MCP client) can inspect and change it -- behind deny-by-default policies, an audit log, and an automatic rollback for configuration changes.
Local and private. The server makes no outbound connections of its own and sends no
telemetry; it listens on loopback and on a root-only socket, and talks to your client over an
SSH session you set up. The tools that can make the router itself reach out (net_diag,
pkg_query with refresh, pkg_change, sysupgrade check, the uci_apply probes, ubus_call,
exec) say so in their MCP annotations (openWorldHint), and each needs a grant from you.
Secret values are masked in tool output by default, and a new WireGuard key never enters the
conversation unless you ask for it.
Footprint. One static binary with no dependencies: 9.1 to 11.3 MB for the router architectures (9.6 MB on arm64, about 3.7 MB compressed in the release archive), about 10 MB resident on a GL-MT6000 right after start, and 23 tools in about 22 KB of tool descriptions. Every release file is checksummed and carries a build attestation.
Based on GlassOnTin/openwrt-mcp by Ian Williams, which targets GL.iNet firmware 4.x (OpenWrt 21.02,
opkg). This is a port to stock OpenWrt 25.12 (apk, fw4/nftables, procd, netifd, LuCI JS). The security core is upstream's; see Credits for what came from where.
Requirements
- Router: stock OpenWrt 25.12 (
apk) or 24.10 (opkg), both with fw4/nftables. Nothing to install on the router beforehand. The package tools pick the manager from the binaries on the router (apkfirst, thenopkg);system_statussays which one and which firewall it found.- What differs on 24.10:
pkg_queryhas nopolicyand noaudit(opkg has neither; the tool says so).pkg_changesimulates withopkg --noaction. New package defaults arrive as<file>-opkginstead of<file>.apk-new, andpkg_config_diff/pkg_config_resolvehandle both (the old copy is kept as.pre-opkg-new; for/etc/config/NAMEas the hidden.NAME.pre-opkg-new, whichreload_configskips). Adding or removing a WireGuard peer re-runsifupfor the interface (24.10's handler cannot sync peers in place), so every tunnel's sessions restart for a moment.worldlists the packages opkg marks "user installed".system_status mode=doctorreportsopkg-new-pending;mode=auditskips the package audit and says why. - fw3 (OpenWrt 21.02 and older, or firmware built on it): best effort.
firewall_showanswersruleset(iptables-save, IPv4) andrendered(fw3 -q print);check,tableandchainare refused; a firewall apply is not pre-checked. - Tested on: 25.12.5 on a GL-MT6000 (hardware); the unit tests run both managers on fakes; CI runs the package and uci
tools in the
openwrt/rootfscontainer of 24.10 and 25.12 (no ubus or netifd there, so network, wireless and firewall paths are not covered). Firmware derived from OpenWrt (GL.iNet, ImmortalWrt) is best effort.
- What differs on 24.10:
- Your PC: OpenSSH (Windows 10 and later, macOS and Linux have it). Installing from a release
needs nothing else. Building from source needs Go 1.26+,
sshandtar(Linux, macOS, or Git Bash on Windows). - Any CPU OpenWrt and Go share: the release has a binary for each (arm64, armv5/v7,
mips/mipsle softfloat, mips64/mips64le, x86, x86-64, riscv64);
install.shreads the router'sDISTRIB_ARCHand picks it, or cross-compiles when you build from source.
Tested on a GL.iNet GL-MT6000 (mediatek/filogic, aarch64_cortex-a53) with OpenWrt 25.12.5.
Nothing in the code is specific to that board.
How it differs from other OpenWrt MCP servers
From each project's own README on 2026-10-07; "not stated" means the README does not say. Corrections are welcome.
| this project | openwrt_ssh_mcp | paulomac1000/openwrt-mcp | openwrt-luci-mcp | |
|---|---|---|---|---|
| Runs on | the router | a PC, in Docker | a PC, in Docker or Python | a PC, in Node.js |
| Reaches the router by | SSH to a key-bound forced command, or loopback HTTP | SSH | SSH | LuCI's HTTP /ubus |
| Changes configuration | yes, through one tool that checks first and arms a rollback | yes, including packages and a firmware flash | read-only unless ENABLE_WRITE_OPERATIONS=1 | no |
| Automatic rollback of a bad change | yes, and it survives a reboot | not stated | not stated | not applicable |
| Access control and audit | per-client grants with scopes, expiry and a rate limit, audit log, optional TOTP | command whitelist, audit log | audit log | secrets hidden, no audit log |
| Package manager | apk (25.12) or opkg (24.10) | opkg | opkg | lists installed and available |
| Flashing firmware | never | yes | not stated | no |
| Needs on the router | the binary | SSH | SSH | uhttpd-mod-ubus, rpcd |
What changed from upstream
| upstream 0.5 (21.02 / GL.iNet) | this port (25.12) | |
|---|---|---|
| Packaging | .ipk for opkg | install.sh file install + keep.d (25.12's apk has no mkpkg; see below) |
| Web UI | GL.iNet oui-httpd page | LuCI page, Services -> MCP Server: status, and approve or deny requests |
| Transport | HTTP over an ssh -L tunnel | same, plus stdio over SSH via a key-bound forced command -- no tunnel to keep alive |
| Rollback snapshot | /tmp (lost on reboot) | on flash; an unconfirmed change is rolled back at the next start, including after a power cycle |
uci_apply | set / create / delete | + add_list, del_list, set_list, dry_run, shows the staged uci changes, strict name validation. A dry run ends with advice, warnings from this change. |
| Reload | ubus call uci reload_config (async) | /sbin/reload_config, plus explicit config.change events when it has no checksums (first run after boot) |
| WireGuard | GL wireguard_server / gl_ddns | stock network.<iface> + wireguard_<iface> peers, DDNS from ddns, CGNAT warning, PSK, list and remove |
| Packages | -- | pkg_query, pkg_change (simulate first), .apk-new review with a settings-level diff |
| Firewall | -- | firewall_show: nft ruleset, fw4 print, fw4 check |
| Services | -- | service_list / service_control via rpcd rc |
| Scope globs | [0] was a character class | brackets are literal: firewall.@rule[3].* means what it says |
| Presets | -- | `openwrt-mcp allow @readonly |
Tools
| Tool | Scope | |
|---|---|---|
system_status | tool | Model, release, uptime, load, memory, storage, temperatures, conntrack, every interface (state, addresses, default route, errors), radios/SSIDs, and what is outstanding. Call first. mode=doctor returns ranked health findings (radio down, interface without an address, enabled service that died, conntrack and storage nearly full, pending .apk-new, NTP or an unset clock, a newer kernel installed than running). mode=audit returns ranked security findings (what the firewall lets in from the internet, SSH and LuCI exposure, password logins, UPnP, WPS, open or weak Wi-Fi, an empty root password, modified package files, stale WireGuard peers). Both are advisory, each finding names a next read-only call and an OpenWrt wiki page, and both list what they could not check. They are separate scopes: openwrt-mcp allow <client> system_status doctor. |
logread | tool | Whole log buffer filtered (substring, RE2, last N minutes) before the line limit; offset pages back from the newest line. mode=summary collapses it to distinct messages (numbers, addresses and ids become placeholders) with count, first and last time and worst severity. baseline=save returns a token; baseline=<token> shows only the messages that were not in the log then. Baselines hold fingerprints, not log text, and live in memory. |
network_clients | tool | DHCP leases + static hosts + neighbour table + every AP's association list, joined by MAC: name, IP, SSID, signal, rates, connected time, each client's share of its radio's airtime (from hostapd), lease; 100 rows a page, offset for the next. |
firewall_show | tool | nft list ruleset, fw4 print, fw4 check, one table or chain. |
net_diag | <action>[.<target>] | ping / traceroute / nslookup (optionally from a given interface), routes, policy rules, neighbours. wifi_survey reports, per radio, how busy the channel it stays on has been (busy, rx and tx airtime, noise); other channels are unmeasured, so it never recommends one. traffic samples interface rates for a few seconds and ranks the conntrack table by source. usage reads nlbwmon's totals per device for an accounting period (a month by default). All three only read. |
uci_get | <config>[.<section>[.<option>]]; refs for a name search | uci show, optionally with stable cfgXXXXXX ids. Ends with the config's revision. history=list / diff:<id> shows the kept versions from before each confirmed change. refs=NAME finds what defines and uses a name across configs. |
uci_apply | <config>.<section>[.<option>] per change; <config> for restore; probe.<kind>.<target> per probe | Stage, check, commit, reload, rollback armed. dry_run shows exactly what would change and what the service's own checker says. expected_revisions refuses if a config moved meanwhile, probe checks the router afterwards, restore=<id> puts a past version back. After the commit the config is read back; a change the router did not keep is NOT_APPLIED and nothing is reloaded. |
uci_confirm / uci_rollback | tool | Make a pending change permanent / undo it now. |
service_list / service_control | tool, <service>.detail / <service>.<action> | procd services; service_list detail=NAME returns an installed add-on's own status (tailscale, AdGuard Home, podkop; the plain list names the ones installed here); stopping or disabling dropbear, network, rpcd or openwrt-mcp is refused. service_control waits for the state to settle (wait) and says if it did not; a final state that contradicts the action is NOT_APPLIED. |
pkg_query | tool | installed, upgradable, search, info, files, owner, policy, apk audit, world; 200 lines a page, offset for the next. |
pkg_change | <action>.<pkg> / upgrade | apk add/del/upgrade; simulates unless commit=true; reports new .apk-new files; checks /etc/apk/world afterwards. |
pkg_config_diff | tool | Every .apk-new as a diff against the live file -- for /etc/config/* by setting (uci show), so quoting/indentation noise disappears. |
pkg_config_resolve | live path | keep_current (drop the new default) or use_new (install it; rollback-armed for UCI configs). |
sysupgrade | <action> | list (preserved files), test (validate an image in /tmp), check (owut), backup (mode 0600; the previous archive this tool made is removed). Never flashes. |
wg_list_clients | tool | Peers with handshake age, endpoint, traffic, config/kernel mismatch, duplicate names. |
wg_new_client | wireguard.<iface>; wireguard.<iface>.reveal for reveal=true | Keypair, next free address, peer saved in the network config; uci_confirm keeps it. The private key and config go to a root-only file; you collect them with openwrt-mcp wg-show <name> (config and QR in your own terminal). reveal=true returns them in the result instead. |
wg_remove_client | wireguard.<iface>.<name> | By name, key or section, from the config and the interface; rollback armed (uci_confirm keeps it). Refuses a peer connected in the last 3 minutes, or this session's own, unless force=true. |
ubus_list | (ungated) | Discovery: every object, method and argument signature. |
ubus_call | <object>.<method> | Anything else on the bus. Replies over 8 KB have long arrays pruned. |
exec | argv[0] | One program, no shell. A grant for sh, find, awk, env, ssh and the like is a root shell: allow refuses it without --shell-equivalent. |
mfa_unlock | (ungated) | Opens the TOTP window for MFA-gated tools. |
Prompts. Clients that show MCP prompts (as slash commands, usually) get five recipes:
router-health, who-is-online, secure-my-router, wifi-doctor and upgrade-plan. Each is a
few lines that name the tools to call and end in a report or a proposal, never a change. They use
only what the @readonly preset grants, so a read-only client can follow them to the end, and they
cost nothing until used. Listing or fetching one runs nothing on the router, so no policy applies
and nothing is audited.
Warnings, references, peers and add-ons
Warnings in a dry run. uci_apply dry_run ends with warnings from this change: findings the change would add that the router
does not have today, ranked like the audit's, each with a next step and an OpenWrt wiki page. They are advice, never a refusal.
They cover a port or port forward opened to the internet, WAN zone input or forward set to ACCEPT, SSH password logins switched
on, an SSID left without encryption, a name a change removes or misspells while other sections still use it (ref-removed,
ref-unknown, with a "did you mean" for a case mismatch), and a change that turns off the Wi-Fi network your own session is
connected through (radio-in-use). Something already wrong before the change is not reported again. A real apply does not
repeat them, so run the dry run first.
The management path follows your session. Besides the LAN, its bridge, the SSH listener and the zone and rule for SSH, the
interface your own SSH connection arrives through counts as the management path (the daemon resolves the client address with
ip route get), so changes to a WireGuard tunnel you are connected over need a probe or force=true like changes to the LAN.
Before you rename or delete an interface, zone or device. uci_get refs=lan lists what defines the name (a network interface,
a firewall zone, a device, a radio, an mwan3 member or policy) and every reference field that uses it: zone network,
rule/forwarding/redirect src and dest, dhcp interface, wireless network and device, network device, ports,
route.interface, sqm and mwan3 when installed. "defined as: nothing" means no section has that name (a typo or a case
mismatch). Only these fields are read, so the search cannot be used to look for a key. It needs the scope refs, which
@readonly and @operator include; config=firewall narrows it to one config.
Adding and removing WireGuard peers is a normal, rollback-armed change. The peer is written to /etc/config/network and netifd
loads it, so other peers' sessions stay up. Both tools wait for the peer to appear on (or leave) the running interface and say
what they verified. Nothing is permanent until uci_confirm: otherwise, after the timeout or a restart, the peer is taken out
again and a new client's key file is deleted. Only one change can wait for confirmation at a time. Removing the peer your own
session arrives through needs force=true.
Add-on status. service_list detail=NAME reads an add-on that is installed (its init script exists) and prints a
fixed set of fields, read-only: it never echoes a config file or a command's raw output, because those hold login
hashes, proxy links, node keys and tokens. A grant needs the service's scope (tailscale.detail, or * as in
@readonly); the plain list needs none. Text that someone else chose, such as a tailnet peer's host name, is clipped,
sanitised and marked untrusted.
| Add-on | What it reports | Verified |
|---|---|---|
| tailscale | state, tailnet DNS suffix, this node's addresses, advertised routes, exit node, health, peers (online, direct or relayed, traffic) | GL-MT6000, 25.12 |
| AdGuard Home | running, listen addresses, protection and filtering switches, upstreams (scheme and host only), bootstrap, filter list and rule counts, whether dnsmasq forwards to it | GL-MT6000, 25.12 |
| podkop | version, sing-box running, nft table, dnsmasq path, settings, one line per section (type, interface, community lists, size of dynamic lists) | GL-MT6000, 25.12 |
Deliberately not read: AdGuard Home's web API (it needs the UI login), podkop's proxy_string, selector and outbound
settings, and the user's domain and subnet lists.
Install
On the router, from a release (nothing needed beyond a default 25.12 image):
wget -O /tmp/install-router.sh https://github.com/stanislav-testhub/openwrt-mcp/releases/latest/download/install-router.sh
sh /tmp/install-router.sh
It picks the archive for the router's DISTRIB_ARCH, checks it against the release's
SHA256SUMS (refusing on a mismatch), and installs. VERSION=v1.2.0 selects a release other
than the latest. Each release file also carries a build attestation:
gh attestation verify <file> --repo stanislav-testhub/openwrt-mcp.
From a PC, building from source (needs Go, ssh and tar; Git Bash works on Windows):
git clone https://github.com/stanislav-testhub/openwrt-mcp
cd openwrt-mcp
ROUTER=root@192.168.1.1 SSH_PORT=22 SSH_KEY=~/.ssh/id_ed25519 ./install.sh install
ROUTER, SSH_PORT and SSH_KEY are your router's root login (defaults: root@192.168.1.1,
22, your ssh defaults). ./install.sh install --release [vX.Y.Z] skips the build and has the
router fetch a verified release instead; that is also what happens when no Go toolchain is found.
Either way the same installer runs on the router.
- It installs:
/usr/bin/openwrt-mcp;- the init script;
- the default config, never overwriting an existing one;
- the
keep.dentry; - the LuCI page.
- It then enables and starts the service.
- It refuses to restart the daemon while a
uci_applyawaits confirmation, because that would roll it back, unlessFORCE=1. - It checks that the binary fits on the overlay first.
./install.sh uninstall [--purge] reverses it.
Approve from LuCI
After install, Services -> MCP Server in LuCI shows the daemon (running or stopped, version, HTTP
listener, stdio socket), a pending uci_apply with its automatic-rollback deadline, the paired HTTP
clients, the standing policies and the audit log. It is also where you answer an agent, so nobody has to
open a shell to paste an allow line.
- The agent calls a tool it has no grant for. The call is refused and a request is recorded: client, tool, the exact scope that failed and the redacted arguments, all derived by the daemon, with no text from the agent.
- The request shows at the top of the MCP page and in a widget on Status -> Overview, which appears only while something is pending.
- Allow writes a grant, Deny refuses it. The default grant is the narrowest one (this tool on this
exact scope, 1 hour). A select widens it (the scope's family, every scope, the
@operatorpreset) and sets 15 minutes to 7 days. A request for a root-shell tool shows a warning and needs a second confirmation. - With
option request_wait '300'(seconds, 0 to 600, default 300; 0 refuses at once) the denied call is held and continues by itself when a grant covers it, so the agent does not retry. Without it, or when the wait runs out, the agent getsPOLICY_DENIEDwith the request id, and a retry after approval works. Theallowline is still printed, and the CLI releases a held call the same way the button does.
Requests are deduplicated, capped (5 open per client, 50 in all) and expire after request_ttl (10 minutes
by default). They live in RAM, so a reboot clears them. A LuCI user with only the read ACL sees requests
but no buttons; writes need luci-app-openwrt-mcp-write.
Other tabs: Permissions (presets, tools in groups with a risk badge, Revoke), Activity (the audit log with filters), Settings (request wait and TTL, the optional channels, second-factor state).
Permit all. The Permissions tab ends with a red section that grants a client every tool on every scope,
exec included: a root shell. It is capped at 24 hours (no "never"), needs a checkbox and the client's name
typed in, is refused for a client with a second factor enrolled, and shows a red banner on both pages with
End now while it lasts. Calls it authorises are marked "via":"permit-all" in the audit log. The CLI
equivalent is openwrt-mcp allow <client> @all 1h --shell-equivalent. Prefer approving requests one by one.
Optional channels are off after install; each is a switch on Settings, with a notice beside it saying how it can harm you, and each switch is audited.
| Channel | Option | What it does | How it can harm you |
|---|---|---|---|
| Pair from LuCI | pairing | A Clients tab: paste the key that openwrt-mcp connect prints, name the client, pick a starting preset (Read-only by default). Writes the same forced-command line as authorize-key. | Anyone with LuCI write access can add a key that reaches the router over SSH. |
| Push hook | push_hook | Each new request fires the ubus event openwrt-mcp.request and runs the executables in /etc/openwrt-mcp/hooks.d/ with the redacted request as JSON on stdin (10 s limit; group- or world-writable, non-executable and dot files are skipped). For Telegram, ntfy or Home Assistant. A notification does not approve; it points at LuCI. | Request details leave the router, and a hook runs as root. |
| Approve in chat | chat_approve, list chat_approve_client | A client that negotiates elicitation also shows the request in its own prompt. Exact scope only, 15 minutes, 1 hour or 8 hours, never for a root-shell tool or Permit all. | The client relays your answer. A client or agent that auto-answers prompts would grant itself. |
What an agent cannot do. A LuCI administrator is already root, so a button gives no person more power. What
must not happen is an agent widening its own grant, so a built-in deny floor refuses, whatever the grant
(@operator and @all included): ubus_call to the objects openwrt-mcp, session, file and rpc-sys
and uci writes to the openwrt-mcp and rpcd configs; exec of the binary and its rpcd plugin, or on the
policy, the rpcd config, /etc/openwrt-mcp and the request state; and uci_apply, history restore and
pkg_config_resolve on those two configs. A client that already holds a root shell is outside what the floor
can claim: see SECURITY.md. Design note: docs/luci-approvals.md.
Run on a real OpenWrt 25.12.5 router (including the LuCI phone layout) and on a 24.10.8 VM: the LuCI pages rendered in headless Chromium, the approve, deny and root-shell confirmation flow, rpcd refusing a write call for a read-only login, and the Overview widget showing only while a request is pending. Screenshots will be added.
Why not an .apk: 25.12 packages are apk-tools v3 ADB archives. Building one needs the
OpenWrt SDK or a host apk-tools with mkpkg (the router's apk has no mkpkg), and an
unsigned package then needs --allow-untrusted anyway. A static binary loses nothing by being
installed as files: /etc/config survives sysupgrade on its own, and /lib/upgrade/keep.d/openwrt-mcp
carries the binary, init script, rc.d links, state and LuCI page. (An owut/ASU image
rebuilds packages, not loose files -- the keep.d entry is what preserves this across one.)
Connect
openwrt-mcp connect (recommended)
The release has a binary for your PC too (openwrt-mcp_<version>_windows_amd64.zip, darwin_arm64.tar.gz
and so on; a Linux PC can use the linux_ archive). Unpack it anywhere on your PATH; it needs
OpenSSH, which Windows 10 and later, macOS and Linux have. Then, on your PC:
openwrt-mcp connect --client claude-code --host 192.168.1.1 # or claude-desktop, codex, cursor, gemini, vscode
It makes a dedicated ed25519 key (~/.ssh/openwrt_mcp, never overwriting one), prints the two
commands to run on the router once, and shows the entry to add to your client. Add --write and it
does that last step: for Claude Code and Codex by running their own mcp add, for Claude Desktop,
Cursor, Gemini CLI and VS Code by merging one openwrt entry into their JSON file (other servers
stay; the original is kept once as <file>.before-openwrt-mcp; a file with comments is left alone and
the snippet printed instead). The entry stores ssh and its arguments as a list, so spaces in a key
path need no quoting, and it sets keep-alives so a router that rebooted is noticed.
openwrt-mcp connect doctor --host 192.168.1.1
checks the chain in order and stops at the first break, with one fix for it:
[ ok ] ssh reachable 192.168.1.1:22
[ ok ] host key
[ ok ] key accepted /home/you/.ssh/openwrt_mcp
[FAIL] forced command the key logged in, but nothing answered as an MCP server (...)
fix: the key is probably authorized without its forced command ...
[skip] daemon socket
The steps are: SSH reachable, host key, key accepted, forced command, daemon socket, initialize,
tools/list. Wrong key, a missing forced command, a stopped daemon and a disabled socket each name
their own step.
After a reboot or a daemon restart. A reboot, an install.sh upgrade or a crash ends every open session: the
openwrt-mcp stdio bridge exits with daemon closed the connection, and the client shows the server as disconnected.
MCP clients do not all reconnect by themselves, so reconnect from the client (its MCP menu, or restart the client).
SSH answers a few seconds before the daemon is listening after a boot; the bridge waits up to 10 seconds for the
daemon's socket before it fails with daemon not reachable ... after waiting 10s (and [code: TIMEOUT; ...]), so a
reconnect right after the router comes back normally just works, and if it does not, wait a few seconds and reconnect again.
A change that was still waiting for uci_confirm is rolled back at the next start. openwrt-mcp status shows the last
disconnect and why (client closed the connection, connection error: ..., or the daemon restarted (router reboot, upgrade or crash)), read from the audit log, which also carries one stdio session closed after ... line per session and
one daemon started line per start.
Narrower sessions, and the shell
Ask for less than the key allows. The policy decides what a client may do; a client can also ask for less for one session.
openwrt-mcp connect --client claude-code --host 192.168.1.1 --read-only # nothing that changes the router
openwrt-mcp connect --client claude-code --host 192.168.1.1 --toolset diag,pkg # only those tools
Toolsets are diag (logread, network_clients, firewall_show, net_diag, service_list, ubus_list, ubus_call),
config (uci_get, uci_apply, uci_confirm, uci_rollback, service_control), pkg (pkg_query, pkg_change,
pkg_config_diff, pkg_config_resolve, sysupgrade) and wg (the three WireGuard tools). system_status is always there;
exec is in no toolset. --read-only is the @readonly preset intersected with the client's grants, so it can only remove
things, and a hidden tool is refused when called, not just left out of the list. The profile travels as words after the forced
command (SSH_ORIGINAL_COMMAND), which the daemon parses against a fixed grammar: anything else is refused and audited, and
there is no word in it that widens a session.
From a shell or a script, no MCP client. The same SSH key runs one tool at a time:
ssh -T root@192.168.1.1 call --list
ssh -T root@192.168.1.1 call uci_get --help
ssh -T root@192.168.1.1 call uci_get '{"config":"network"}'
ssh -T root@192.168.1.1 -- --read-only call uci_get '{"config":"network"}' # after a profile word, `--` ends ssh's own options
The result is text on stdout; a refusal or error goes to stderr with the allow line and exit status 1. The call runs through
the same policy, audit and redaction as an MCP session. skills/openwrt-mcp/SKILL.md is a ready-made skill file for agents
that prefer a shell to MCP.
Limits. Each tool has a deadline above its own waits, and at most 8 calls run at once: the rest wait up to 30 s and then
get a busy refusal (TIMEOUT). A client that cancels a call kills the command it started. uci_confirm, uci_rollback and
mfa_unlock never wait for a slot.
stdio over SSH, by hand
A dedicated key, bound on the router to the MCP bridge and nothing else:
ssh-keygen -t ed25519 -N '' -f ~/.ssh/openwrt_mcp
ssh root@router "openwrt-mcp authorize-key claude-code '$(cat ~/.ssh/openwrt_mcp.pub)'"
ssh root@router "openwrt-mcp allow claude-code @readonly 30d"
claude mcp add openwrt -- ssh -T -i ~/.ssh/openwrt_mcp -o BatchMode=yes root@router
authorize-key writes a line like
command="/usr/bin/openwrt-mcp stdio --client claude-code",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 ...
into /etc/dropbear/authorized_keys: that key gets the MCP server as client claude-code and
cannot open a shell or forward ports. It refuses a key that is already a root login.
The bridge pipes the session into a root-only unix socket (/var/run/openwrt-mcp/mcp.sock,
0600 in a 0700 directory). The daemon runs the tools, so a rollback armed in one session
outlives it. The client name on the socket is asserted, not proven -- sound because only root
can connect, and root can already edit the policy file; the SSH key is the real
authentication.
On Windows, the built-in OpenSSH client works for this (ssh.exe); use a full path to the key.
HTTP over a tunnel
Unchanged from upstream: openwrt-mcp pair <client> prints a bearer token once;
ssh -N -L 8730:127.0.0.1:8730 root@router; then
claude mcp add --transport http openwrt http://127.0.0.1:8730/mcp --header "Authorization: Bearer $TOK".
The daemon refuses to bind anything but loopback.
Make the policy mean something
If the agent can also run ssh root@router ... through its shell tool, the policy is
advisory. Deny that in the client (Claude Code: a permissions.deny rule for
Bash(ssh * root@<router>*)), or keep the root key where the agent cannot read it.
Policies
Deny by default. A policy grants one client a list of tools, scope globs (* and ? are
wildcards; brackets are literal), a calls/minute ceiling and an expiry. Changes to
/etc/config/openwrt-mcp take effect without a restart.
openwrt-mcp allow claude-code @readonly 30d # every read tool; ubus_call on an allow-list of read methods
openwrt-mcp allow claude-code @operator 7d # + uci_apply/confirm/rollback, service_control, pkg_change,
# pkg_config_resolve, wg_new/remove_client, sysupgrade backup
openwrt-mcp allow claude-code uci_apply 'dhcp.* wireless.*.disabled' 30d
openwrt-mcp allow claude-code service_control 'dnsmasq.restart adguardhome.*' 30d
openwrt-mcp revoke claude-code # remove every policy for the client
openwrt-mcp prune --older-than 7d # delete grants that expired over a week ago (audited)
openwrt-mcp policies | status [--all] | clients
Granting the same tools and scopes again replaces the client's expired grant instead of adding
a block. status counts expired grants in one line; --all lists them.
Neither preset includes exec or unrestricted ubus_call; both bypass the safety rails. The
read-only ubus list is explicit object.method pairs, not *.list-style globs, because
session.list returns LuCI session ids. uci_get '*' reads every config, but the values of
secret options (Wi-Fi keys, WireGuard keys, passwords, tokens) come back as <redacted>; see
Safety model. exec is not masked.
A refusal names the uncovered scope and prints the exact allow line that would cover it.
An exec scope is argv[0] taken literally, so a grant for find or awk looks harmless and
is a root shell: both run other programs, and so do the shells, env, nice, flock,
timeout, xargs, tar, ssh, lua, ucode, apk and opkg. allow refuses such a grant
until you add --shell-equivalent, judging by base name so /usr/bin/../bin/sh and /bin/*
count too; a ubus_call grant that can reach file.exec is treated the same way.
openwrt-mcp policies and status mark these grants, which also covers a policy written by
hand. The list is a floor, not a promise: a grant that writes files (wget, cp, dd) or
starts an interpreter that is not on it can still be abused.
Second factor
Unchanged from upstream: openwrt-mcp mfa enrol <client> prints an otpauth:// URI; then in a
policy list mfa_tools 'uci_apply', option mfa_window '15m'. One code opens a time-boxed
window; codes are single-use; unlocks live in memory only.
Enrolling alone gates nothing. Only a config policy section in /etc/config/openwrt-mcp with
mfa_tools is gated, and mfa_tools may name only tools that same policy grants. Grants made with
openwrt-mcp allow or from the LuCI page never require a code. openwrt-mcp mfa status says when an
enrolled client has no gating policy.
Wrong codes are limited: in the config server section, option mfa_max_failures '5' consecutive
wrong codes lock that client out of mfa_unlock for option mfa_lockout '300' seconds ('5m' also
works), doubling on each repeat to one hour. A correct code during the lockout is refused and not
spent. A client with no secret counts the same, so the limit reveals nothing about enrolment, and
the counters are in memory (a restart, mfa enrol or a rotated secret clears them). Each wrong code
is audited as ERROR and a lockout as DENIED; the code itself is never logged. The price: whoever
holds a client's token can keep that client locked out for up to an hour.
Safety model
Documentation truncated — see the full README on GitHub.
Reviews
No reviews yet
Be the first to review this server!
More Developer Tools MCP Servers
Rea
Freeby Morluto · Developer Tools
Reverse engineer anything from your terminal or agent with one CLI and MCP server.
Paperclip
Freeby Paperclipai · Developer Tools
Trending hip-hop artist momentum scores across four cultural dimensions.
Fetch
Freeby Modelcontextprotocol · Developer Tools
Web content fetching and conversion for efficient LLM usage
Git
Freeby Modelcontextprotocol · Developer Tools
Read, search, and manipulate Git repositories programmatically
Toleno
Freeby Toleno · Developer Tools
Toleno Network MCP Server — Manage your Toleno mining account with Claude AI using natural language.
MCP Marketplace
Freeby mcp-marketplace · Developer Tools
Search and install MCP servers from inside your AI client.
