mcp-mikrotik
An MCP server for MikroTik RouterOS devices that lets an LLM read live router state and, when explicitly enabled, perform narrowly-scoped guarded writes.
Discover and inspect configured devices (
list_devices, passwords never exposed) and device identity/resource info (system_info).Read network state: interfaces, IPv4 addresses, routing table, discovered neighbors.
Read logs with a
topicssubstring filter and cappedlimit.Run diagnostics from the device itself (
ping, with IPv4/IPv6/hostname validation).Inspect what guarded writes exist vs. the RouterOS paths they map to (
list_write_operations) without triggering one.Perform guarded writes (e.g.
set_identity) — read-only by default behindMIKROTIK_ALLOW_WRITE, allowlisted, and previewed viaconfirm=falsebeforeconfirm=trueapplies.Per the README, the server also covers DHCP, wireless/wifi, VLANs, PoE, LTE/5G, containers, USB, VPN (WireGuard/PPP/IPsec), BGP/OSPF, Netwatch failover, DNS, firewall filter/NAT/mangle toggles, hotspot vouchers, backups, IPv6, and a heuristic security audit.
Safety posture: no generic "run any command" tool, stdio-only transport (no HTTP), secrets stripped from output, and an audit journal for every write.
Includes a dead-man / lockout-proof write primitive for risky wireless RF changes that could sever the management path.
Manages MikroTik RouterOS devices: reads device state (interfaces, routes, neighbors, logs, ping) and allows a limited set of guarded write operations such as setting the device identity.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-mikrotik show system info for device office-router"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcp-mikrotik
A Model Context Protocol server for MikroTik RouterOS devices. It lets an MCP client (Claude Desktop, Claude Code, or any other MCP-compatible LLM tool) read a router's live state - interfaces, routes, DHCP, wireless, VPN, firewall, containers, logs, live traffic - and diagnose it (ping, traceroute, torch), so an operator or an LLM can answer "what's going on with this network" without opening WinBox. For a small, explicit, individually-reviewed set of changes, it can also write - every write is read-only by default, gated behind a central allowlist, and previewed before it's applied.
Philosophy: start read-only, make writes something you opt into and can
review, never something an LLM reaches by accident. MIKROTIK_ALLOW_WRITE
defaults to false - point this at a fleet and it can only ever read until
you deliberately turn writes on. Even then there is no generic "run this
RouterOS command" tool: every write is a dedicated, named function mapped to
exactly one API path, and every one of them supports confirm=false to
preview a change before confirm=true applies it. See "Security model"
below for the full mechanism.
This is a from-scratch implementation, not a fork. It exists to correct a
set of concrete failures found in an earlier project during a security
audit: an unrestricted generic "run any API command" tool, an HTTP
transport bound to 0.0.0.0 with no auth, command injection via
string-built SSH calls, and no tests. See "Security model" below for how
each of those is avoided here.
Status
1.11.0. Every planned tool round through Tier 2 has shipped, plus
Tier 3's IPv6 parity item in full (reads in v1.9, writes in v1.10), plus
this round's headline feature: the dead-man / lockout-proof write
primitive (arm_dead_man/cancel_dead_man) and the wireless RF tuning
tools it unlocks (set_wireless_channel/set_wireless_tx_power/
set_wireless_tuning/get_wireless_link_quality) - see "Dead-man /
lockout-proof writes" and "Wireless RF tuning" below. The full read-tool
inventory (interfaces, VLANs, routing IPv4 and IPv6, DHCP servers/
leases/networks, wireless (raw + normalized link-quality), VPN/WireGuard/
PPP, containers, LTE/5G, USB, hotspot, live traffic, backups, firewall
filter/NAT/mangle IPv4 and IPv6, bridge ports & VLAN filtering,
SFP/optical monitor, certificates, users/RADIUS, NTP/clock, a heuristic
security audit), guarded writes across identity/interfaces/wifi/wireless-RF-
tuning/dead-man/bandwidth/DHCP/address-lists (IPv4 and IPv6)/PoE/
containers/failover-routing (IPv4 and IPv6)/Netwatch/DNS/Wake-on-LAN/
firewall-rule-toggle (filter/NAT/mangle, IPv4 and IPv6)/firewall-rule-
reorder/WireGuard/hotspot-vouchers/backup/VLANs/PPP-PPPoE-secrets/
NTP-servers, and the production-hardening layers this needs to run
unattended against a real fleet - audit journal, correlation IDs, read
retry, circuit breaker. See CHANGELOG.md for the full version-by-version
history, and ROADMAP.md for what's next (and what's deliberately out of
scope, and why).
The full pytest suite currently has 1794 tests (114 tools), all passing
against an in-memory fake device layer (pytest -q), at 100% line coverage
(CI enforces a 95% floor) - see "Development & CI" below. Many tools are
additionally smoke-tested against real ROS6/ROS7 hardware before release
(the v1.10 IPv6 writes are an exception - see CHANGELOG.md's v1.10 entry;
v1.11's wireless RF tools were hardware-verified, see CHANGELOG.md's
v1.11 entry and docs/api-notes-wireless-rf.md).
Related MCP server: vyos-mcp
Installation
Requirements:
Python 3.11+.
A MikroTik device reachable over the RouterOS API (not WinBox, not SSH) - plain API port
8728, orapi-sslport8729. RouterOS 6.49+ (the legacywirelesswifi stack) or 7.x (thewifipackage) are both supported; several read/write tools (wireless_registrations,set_wifi_ssid,bgp_sessions) detect which generation a device speaks and use the matching path automatically.
With pip:
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"With uv:
uv venv
source .venv/bin/activate
uv pip install -e ".[dev]"Either way this installs the mcp-mikrotik console script (see "Running"
below) plus .[dev] (pytest/pytest-asyncio/pytest-cov) for running the
test suite locally.
Configuration
Configuration comes from environment variables plus an optional
devices.yaml file.
Copy the examples:
cp devices.yaml.example devices.yaml cp .env.example .envEdit
devices.yamlwith your real devices and credentials. This file is git-ignored - it is never meant to be committed.Edit
.env(or export the variables another way) to control server-wide behaviour:Variable
Default
Meaning
MIKROTIK_DEVICES_FILEdevices.yamlPath to the devices YAML file
MIKROTIK_ALLOW_WRITEfalseEnable write tools (see Security model)
MIKROTIK_LOG_LEVELINFOLog level for the server process (stderr); invalid values fall back to
INFOwith a warningMIKROTIK_TIMEOUT10Fallback connect timeout (seconds) for devices without their own
timeoutMIKROTIK_AUDIT_LOG(unset)
File path for the JSON-lines write-audit journal; unset logs each event via
logging(stderr) instead. See "Production features" below.MIKROTIK_READ_RETRIES2Extra retry attempts for read operations on a transient network error
MIKROTIK_BREAKER_THRESHOLD3Consecutive connection failures before a device's circuit breaker opens
MIKROTIK_BREAKER_COOLDOWN30Seconds a device's circuit stays open before a trial reconnect is allowed
Each device entry supports its own port and use_ssl, since a fleet is
commonly a mix of plain API (8728) and api-ssl (8729) devices, and possibly a
mix of RouterOS 6.x and 7.x. It can also override timeout (seconds; falls
back to MIKROTIK_TIMEOUT, then 10s - useful for devices behind a slow
link) and, when use_ssl: true, tls_verify (see "TLS verification for
api-ssl" below).
Running
The server speaks MCP over stdio - it is meant to be launched by an MCP client (e.g. configured as a command in Claude Code), not run as a network service:
mcp-mikrotik
# or, without installing the console script:
python -m mcp_mikrotik.serverThere is no HTTP transport at all - stdio only. If one is added in a future
release, it must default to binding 127.0.0.1 (never 0.0.0.0) and
require a bearer token from an environment variable - see the
TODO(http-transport) note at the top of src/mcp_mikrotik/server.py.
Connecting an MCP client
Since mcp-mikrotik speaks MCP over stdio, any MCP-compatible client can
launch it as a subprocess. For Claude Desktop,
add it to claude_desktop_config.json:
{
"mcpServers": {
"mikrotik": {
"command": "mcp-mikrotik",
"env": {
"MIKROTIK_DEVICES_FILE": "/absolute/path/to/devices.yaml",
"MIKROTIK_ALLOW_WRITE": "false"
}
}
}
}For Claude Code, the equivalent is:
claude mcp add mikrotik --env MIKROTIK_DEVICES_FILE=/absolute/path/to/devices.yaml --env MIKROTIK_ALLOW_WRITE=false -- mcp-mikrotikUse an absolute path for MIKROTIK_DEVICES_FILE - an MCP client
typically launches the server from its own working directory, not this
project's. Leave MIKROTIK_ALLOW_WRITE=false (the default) until you've
read "Security model" below and deliberately want write tools enabled.
Example interactions
Once connected, an LLM caller uses the tools below directly by name. A few representative exchanges:
"What's the status of my core-switch?" → calls
system_infoandinterfaces, summarizes board/RouterOS version/uptime and which interfaces are up or down."Is there a device on 192.168.88.50?" → calls
dhcp_leases(and, if nothing turns up there,arp_table) filtered to that address, to tell a DHCP-assigned host from a statically-addressed one."Limit the guest on 192.168.88.77 to 5 Mbps." → calls
set_client_bandwidth(target="192.168.88.77", max_limit="5M/5M", confirm=false)first, shows the before/after preview, and only calls it again withconfirm=trueonce you confirm - this requiresMIKROTIK_ALLOW_WRITE=trueon the server.
Tools
Read-only
Tool | Description |
| List configured devices (passwords never included). |
| RouterOS identity + resource info (board, version, uptime, CPU/memory). |
| List interfaces; |
| List VLAN interfaces ( |
| List IPv4 addresses. |
| List the IPv4 routing table; optional |
| List neighbors discovered via CDP/MNDP/LLDP. |
| List DHCP server leases (address, mac, host-name, status, server, comment). |
| List Simple Queue entries (name, target, max-limit, limit-at, bytes counters, disabled) - see who already has a bandwidth limit and how much traffic they've moved. |
| List firewall address-list entries (list, address, timeout, dynamic, disabled) - see who's currently in which named list. |
| List IPv4 firewall NAT rules (chain, action, to-addresses, etc). Read-only - does not add/modify/remove rules. |
| List scheduled tasks (name, on-event, interval, next-run, disabled). |
| List IP pools (name, ranges). |
| List wireless clients currently associated to the device. Tries the ROS7 wifi registration table first, falls back to the ROS6 wireless one; returns an empty list (not an error) for a device with no radio. |
| List WireGuard VPN peers (name, interface, public-key, endpoint, last-handshake, rx/tx, allowed-address, disabled). Never exposes a private-key or preshared-key, even defensively. Empty list (not an error) with no WireGuard interfaces. See "VPN & routing diagnostics" below. |
| List WireGuard tunnel interfaces (name, listen-port, public-key, running, disabled, mtu). Never exposes a private-key - RouterOS's own reply genuinely carries one here (unlike |
| List active PPP-based VPN server sessions ( |
| List CONFIGURED PPP/PPPoE secrets ( |
| List active IPsec peers (remote-address, state, uptime, rx/tx, side). Empty list (not an error) for a device that doesn't use IPsec. |
| BGP session status (remote-address/as, state, uptime, prefix-count). Tries ROS7's |
| OSPF neighbor adjacencies (address, state, router-id, adjacency). Empty list (not an error) for a device that doesn't run OSPF. |
| List Netwatch host monitors (host, status up/down, interval, since, comment, disabled, plus |
| List cached DNS records (name, type, data, ttl). |
| List IPv4 firewall filter rules (chain, action, etc). Read-only - does not add/modify/remove rules. |
| List IPv4 firewall mangle rules ( |
| List active connections from |
| System health metrics (voltage, temperature, ...), if the device exposes any; empty list otherwise. |
| Recent log entries; |
| Ping an address from the device; |
| Traceroute to an address from the device; returns the list of hops. |
| List the IPv4 ARP table (address, mac-address, interface, dynamic, complete) - cross-reference IP↔MAC for a statically-addressed device that doesn't show up in |
| List |
| Current rx/tx rate of one |
| PoE configuration + live consumption for every PoE-capable ethernet port on the device (voltage/current/power/ |
| Signal/status of one LTE/5G modem |
| List LTE/5G modem interfaces (name, running, disabled, apn-profiles). Empty list (not an error) with no LTE hardware. |
| List containers (name/tag, status, ram-usage, root-dir, interface, os). Empty list (not an error) with no container package. See "Container management" below. |
| Container subsystem configuration (registry-url, tmpdir, ram-high). Empty dict (not an error) with no container package. |
| USB ports ( |
| List every guarded write operation and the RouterOS path/action it maps to (metadata only, no gate). |
| Read-only, heuristic security audit: aggregates several config reads into |
| Recent |
| List clients currently logged into the RouterOS hotspot (user, address, mac-address, uptime, bytes-in/bytes-out). Empty list (not an error) with no hotspot server / no one logged in. See "Hotspot vouchers" below. |
| Live traffic snapshot of one |
| List backup files on the device ( |
| List certificates ( |
| List RouterOS login accounts ( |
| List currently active RouterOS management login sessions ( |
| List RADIUS server configuration ( |
| Link/optical status of one ethernet |
| List DHCP server config ( |
| List DHCP server networks ( |
| List bridge port membership ( |
| List the bridge VLAN filtering table ( |
| NTP client configuration/status ( |
| Device clock ( |
| List IPv6 addresses ( |
| List the IPv6 routing table ( |
| List IPv6 firewall filter rules ( |
| List the IPv6 neighbor discovery table ( |
| List IPv6 firewall address-list entries ( |
| PtP/PtMP link-quality diagnosis: the same registration-table |
Write (guarded)
Every write tool below requires MIKROTIK_ALLOW_WRITE=true and is called
twice: once with confirm=false (the default) to get a before/after
preview, and again with confirm=true to actually apply it. See "Security
model" below for the full guard mechanism.
Tool | Description |
| Set a device's RouterOS identity (hostname). |
| Enable a network interface by name ( |
| Disable a network interface by name ( |
| Set a wireless interface's SSID. Detects whether the interface lives under the ROS7 wifi package or the ROS6 wireless package and writes to whichever one matches; errors if the interface name isn't found under either. On ROS7, also detects whether the ssid is inline or lives on the interface's referenced |
| Limit a client's bandwidth via a Simple Queue targeting an IP/subnet ( |
| Create a static DHCP lease pinning an IP |
| Remove a Simple Queue by |
| Add an IP/subnet |
| Remove the |
| Set a PoE-capable ethernet port's |
| Start a container by |
| Stop a container by |
| Adjust an existing route's |
| Enable a route ( |
| Disable a route ( |
| Add a static route ( |
| Remove a static route, resolved by |
| Create a Netwatch host monitor ( |
| Remove a Netwatch host monitor by |
| Create a static DNS entry ( |
| Remove a static DNS entry by |
| Flush the device's DNS resolver cache ( |
| Remove a DHCP lease (dynamic OR static) by |
| Send a Wake-on-LAN magic packet ( |
| Enable an EXISTING firewall filter rule ( |
| Disable an EXISTING firewall filter rule ( |
| Create a WireGuard tunnel interface ( |
| Add a WireGuard peer to an existing |
| Remove a WireGuard peer from an |
| Create a hotspot voucher user ( |
| Create a RouterOS system backup file ( |
| Create a VLAN interface ( |
| Remove a VLAN interface by |
| Reorder an EXISTING firewall filter rule ( |
| Create a PPP/PPPoE secret ( |
| Remove a PPP/PPPoE secret by |
| Enable an EXISTING firewall NAT rule ( |
| Disable an EXISTING firewall NAT rule ( |
| Enable an EXISTING firewall mangle rule ( |
| Disable an EXISTING firewall mangle rule ( |
| Set the NTP server(s) a device syncs its clock against ( |
| Enable an EXISTING IPv6 firewall filter rule ( |
| Disable an EXISTING IPv6 firewall filter rule ( |
| Add a static IPv6 route ( |
| Remove a static IPv6 route, resolved by |
| Add an IPv6 |
| Remove the |
| Set a |
| Set a |
| Set a |
| Arm a local, self-removing RouterOS scheduler that reverts |
| Cancel a dead-man scheduler armed by |
Not yet exposed, deliberately: device reboot, backup RESTORE (/system/backup/load
same risk class as reboot), and creating/generally modifying a firewall filter, NAT, or mangle rule (only the narrow
disabledTOGGLE of an existing, admin-authored rule is exposed for all three - see "Firewall rule toggle (by comment)" and "NAT & mangle rule toggle (by comment)" below). See "Roadmap / non-goals" below for why.
Limiting a client's bandwidth (v0.3)
The typical flow to find and limit a client that's consuming too much of the link:
simple_queuesanddhcp_leases/wireless_registrationsto see who's on the network and whether they already have a limit.Optionally,
add_static_dhcp_leaseto pin a chatty client's IP so itstargetdoesn't drift to a different address on DHCP renewal.set_client_bandwidthwithconfirm=falsefirst to preview themax-limit/limit-atit would set (and whether it would create a new queue or update an existing one), thenconfirm=trueto apply it.remove_simple_queue(bytargetorname) to lift the limit later.
FastTrack gotcha: RouterOS's own quick-set wizards commonly add a
FastTrack rule to /ip/firewall/filter for performance. Fasttracked
connections bypass the whole queueing subsystem, including Simple Queue -
so a queue created by set_client_bandwidth can silently have zero effect
on a client whose traffic is already being fasttracked. If a limit doesn't
seem to be taking effect, check firewall_filter for a FastTrack rule and
adjust/disable it for the traffic you're trying to limit. mcp-mikrotik
does not create or generally modify firewall rules - the only firewall
filter write exposed is the narrow disabled toggle of an existing rule
(enable_firewall_rule/disable_firewall_rule - see "Firewall rule toggle
(by comment)" below).
Blocking/allowing a client via address lists (v0.4)
add_to_address_list/remove_from_address_list manage entries in a named
/ip/firewall/address-list - nothing more. Adding an address to a list has
no effect on traffic by itself. It only blocks or allows anything if a
/ip/firewall/filter (or NAT) rule on the device already references that
same list name, e.g.:
/ip firewall filter add chain=forward src-address-list=blocked-clients action=dropmcp-mikrotik does not create or generally modify that rule for you - the
only firewall filter write exposed is the narrow disabled TOGGLE of an
existing, admin-authored rule (enable_firewall_rule/disable_firewall_rule
see "Firewall rule toggle (by comment)" below, and "Roadmap / non-goals" further below for why rule creation itself still isn't) - use
firewall_filterto check whether a rule referencing your list already exists on the device before relying onadd_to_address_listto actually block or allow anyone. The typical flow:
Confirm (once, out of band - e.g. via WinBox/CLI, or just by reading
firewall_filter) that a filter rule referencing your list name exists, e.g.src-address-list=blocked-clients action=dropon theforwardchain.dhcp_leases/wireless_registrations/neighborsto identify the client's IP.add_to_address_listwithconfirm=falsefirst to preview the entry it would add, thenconfirm=trueto apply it. An optionaltimeout(e.g."1d") auto-expires the entry instead of blocking/allowing permanently.address_liststo check current membership;remove_from_address_listto lift a block/allow later.
Physical layer & PoE control (v0.6)
Three tools for the physical/L2 layer, aimed at fleets where devices are powered over PoE from a managed switch (e.g. a MikroTik CRS318-16P-2S+ feeding antennas at different PoE levels - 48V "high" and 24V "low"):
arp_table/bridge_hosts(/ip/arpand/interface/bridge/host- see the read-tools table above) to cross-reference an IP to a MAC, and a MAC to the physical bridge port it's on.interface_trafficfor a live rx/tx reading on one interface.poe_statusfor per-port PoE configuration and live consumption (voltage/current/power/poe-out-status) across every PoE-capable port - empty, not an error, on hardware with no PoE at all.set_poe_outto change a port's PoE output mode.
The killer use case: remote power-cycle a locked-up antenna/camera/AP. Rather than a truck roll to physically unplug/replug a device, if it's powered over PoE from a MikroTik switch:
bridge_hosts(orarp_table) to confirm which physical port the stuck device is on, if not already known.poe_statusto see its currentpoe-out/poe-out-statusand confirm it's actually drawing power (voltage/current > 0) before assuming a PoE cycle will help.set_poe_outwithpoe_out="off",confirm=falsefirst to preview, thenconfirm=trueto actually cut power to the port.Wait a few seconds -
poe_statusagain to confirmpoe-out-statusshows the port is no longer powered.set_poe_outwithpoe_out="auto-on"(preview, then confirm) to restore power.forced-onis also available for ports/devices that need power regardless of RouterOS's own PoE detection/negotiation.
Like every other write tool, set_poe_out never creates or coerces
anything: it raises ResourceNotFoundError if interface_name doesn't
exist on the device at all, or if it exists but has no poe-out field (not
PoE-capable hardware, e.g. an SFP+ cage) - see "Security model" below.
LTE/5G monitoring (v0.7)
For devices with a cellular WAN modem (LTE/5G):
lte_interfacesto see which LTE interfaces exist on the device (name, running, disabled, apn-profiles).lte_statusfor one interface's live signal/status - operator (current-operator), technology (access-technology: 3G/LTE/5G), signal quality (rsrp/rsrq/sinr/rssi),band,registration-status, andcell-id. Built the same "monitor-once" way asinterface_traffic/poe_status(/interface/lte/monitor <interface> once=yes) - a single instantaneous reading, not a stream.
Both return empty (an empty list/dict, never an error) on a device with no
LTE hardware or package at all - the same convention poe_status/
system_health already use for optional hardware.
Container management (v0.7)
RouterOS 7's container package runs OCI containers directly on the device (e.g. a lightweight metrics agent or a small web dashboard, without a separate host). The typical flow:
containersto see what's deployed (name/tag, status, ram-usage, root-dir, interface, os) andcontainer_configfor the subsystem-wide settings (registry-url, tmpdir, ram-high).start_container/stop_containerwithconfirm=falsefirst to preview, thenconfirm=trueto actually apply it.containermatches against a container'snameif it has one, falling back to itstag(the image reference, e.g."grafana/grafana:latest") otherwise - RouterOS only populatesnamewhen the container was created with one explicitly.
Unlike enable_interface/set_poe_out (which flip a field synchronously),
starting/stopping a container fires a RouterOS action command
(/container/start//container/stop) that transitions asynchronously -
the preview's after.status reflects the immediate transitional state
("starting"/"stopping"), not a guaranteed final one. Call containers
again afterward to see the settled "running"/"stopped" status. See
CHANGELOG.md's "How start/stop extends the guard" for how this new
action-command shape was added to the write guard without weakening it.
Like every other write tool, start_container/stop_container never
create a container: an unmatched container raises
ResourceNotFoundError. A device with no container package/hardware
support at all raises the same ResourceNotFoundError (never a raw
device-side error) - the same underlying condition containers() already
degrades gracefully from (returns [] rather than erroring).
USB (v0.7)
usb_devices reads /system/routerboard/usb (physical USB ports, on
boards that expose them) and /disk (attached storage - USB flash drives,
and USB LTE/5G modems that surface as a disk rather than under
routerboard/usb) and returns both as {"usb_ports": [...], "disks": [...]}, since which of the two a given USB device shows up under depends
on the hardware. Either or both lists come back empty (never an error) on a
board with no USB hardware at all.
SFP/optical monitor, DHCP-server config, bridge VLAN filtering (v1.7)
Five read-only tools closing out most of ROADMAP.md's Tier 2 - none of
them touch the write guard or require MIKROTIK_ALLOW_WRITE:
interface_monitor(/interface/ethernet/monitor once=yes): link status/rate/duplex for any ethernet port, built the same "monitor once" way asinterface_traffic/poe_status/lte_status. On a port with an SFP cage AND a module inserted, the reply additionally carries DDM optics fields (sfp-temperature,sfp-supply-voltage,sfp-tx-power,sfp-rx-power,sfp-tx-bias-current,sfp-vendor-name,sfp-vendor-part-number,sfp-wavelength,sfp-module-present) - a plain copper port (the vast majority of ports on most hardware) simply has none of the sfp-* fields at all, andinterface_monitornever invents one. Hardware caveat: this project's reference board (a mANTBox) has no SFP cage, so the command path is confirmed but the DDM field values have not been verified against real SFP optics - treat them as unverified until checked on hardware with an actual module.dhcp_servers/dhcp_networks(/ip/dhcp-server,/ip/dhcp-server/network): the DHCP server's own config (address-pool, lease-time, authoritative, and per-subnet gateway/dns-server/domain) - as opposed to the already-shippeddhcp_leases, which only lists what a server has handed out to clients.bridge_ports/bridge_vlans(/interface/bridge/port,/interface/bridge/vlan): together these complete the VLAN story for a managed switch (CRS/hEX-style hardware). The v1.2 VLAN tools (list_vlans/add_vlan/remove_vlan) only ever operate on standalone/interface/vlaninterfaces - router-on-a-stick style routing, a different RouterOS mechanism from bridge VLAN filtering, which is how VLANs actually get segmented across a switch's physical ports.bridge_portsshows per-portpvid/edge/horizon/learnconfig;bridge_vlansshows thevlan-ids↔tagged/untaggedport membership table, plus RouterOS's own computedcurrent-tagged/current-untaggedwhen the device's reply carries them.
All boolean-shaped fields these five tools touch are normalized with
formatting.coerce_ros_bool (disabled on dhcp_servers/bridge_ports,
full-duplex/sfp-module-present on interface_monitor) rather than
compared against the string "true"/"false" - librouteros hands a
RouterOS boolean back as a Python bool or omits the field entirely, never
that string. Enum-shaped RouterOS fields that merely look boolean
(auto-negotiation, authoritative, edge, learn - each of which can
take a value like "auto" or "after-2sec-delay", not just yes/no) are
deliberately left as RouterOS's own raw value instead.
NTP client + clock (v1.8)
The last item ROADMAP.md's Tier 2 named - closed out here. Two read tools
plus one guarded write:
ntp_client/system_clock(/system/ntp/client,/system/clock): NTP configuration/sync status and the device's own clock. Every field the device's reply actually carries is returned as-is.get-based, nothing invented - only the boolean-shaped ones (enabled,time-zone-autodetect,dst-active) are normalized viaformatting.coerce_ros_bool. Clock drift breaks certificate validation (seecertificates'sdaysUntilExpiry), log timestamps, and scheduler timing - check both tools together when diagnosing any of those.
set_ntp_servers(WRITE, guarded): sets the NTP server(s) a device syncs against./system/ntp/clientis the SAME RouterOS path on both generations (unlikeset_wifi_ssid's genuinely different/interface/wifivs/interface/wirelessmenus) - only the FIELD SHAPE differs, detected by reading the row once and checking which field is present:servers(ROS7's single comma-joined list) orprimary-ntp(ROS6's fixed two-slot form) - the same "read first, then decide" detectionset_wifi_ssidalready established.ROS7: writes the full comma-joined
serverslist.ROS6: has no
serverslist at all.servers[0]maps toprimary-ntp,servers[1](if given) tosecondary-ntp; anything beyond the first two is dropped, withwarningnaming exactly which ones. Older ROS6 firmware only accepts a literal IP in either slot - a hostname destined for one of those two slots is instead folded intoserver-dns-names(RouterOS's own DNS-name field for this menu) IF the device's row shows that field exists; if it doesn't, that hostname is NOT applied (never a value RouterOS would likely reject), andwarningsays so. If nothing at all ends up applicable, the tool raises a validation error rather than silently performing a no-op write.Never enables/disables the NTP client itself - only the server list changes.
warningalso fires if the client is currently disabled (enabled=no): new servers won't be used until it's enabled separately (out of scope here - see "Roadmap & non-goals" below).Hardware caveat: exercised only against this project's fake RouterOS connection (
tests/fakes.py), not real ROS6 hardware - the ROS6 field shapes and the "older firmware rejects a hostname" claim are unverified on an actual device. SeeCHANGELOG.md.
IPv6 read parity (v1.9)
ROADMAP.md's Tier 3 "IPv6 parity" item, READ-ONLY side. Five read
tools, each mirroring an existing IPv4 read field-for-field on the
/ipv6/* equivalent of its path:
IPv6 tool | Mirrors |
|
|
|
|
|
|
|
|
|
|
Trap this release specifically guards against: the entire /ipv6/*
menu subtree raises if the ipv6 package is disabled on the device - a
common, fully supported state, not every deployment runs IPv6. All five
tools above use the same skip-if-missing pattern already established
for other optional features (wireguard_peers, ppp_active,
bgp_sessions, ospf_neighbors, ...): a DeviceCommandError from the
/ipv6/* path is caught and an empty list is returned, never propagated as
an error. Like the IPv4 reads they mirror (ip_addresses/firewall_filter/
arp_table/...), these tools pass RouterOS's rows through as-is - no
server-side boolean coercion. The fixtures/tests use real Python bool
values for disabled/dynamic/advertise/active (matching what
librouteros actually returns), so the tests exercise the real shape.
IPv6 write parity (v1.10)
The WRITE side of the same ROADMAP.md Tier 3 item, closing it out
entirely. Six guarded write tools, each mirroring an existing IPv4 write
tool field-for-field on the /ipv6/* equivalent of its path:
IPv6 tool | Mirrors |
|
|
|
|
|
|
IPv6-only address validation: dst_address/gateway/address on all
six tools are validated as IPv6-only (validate_ipv6_dst_address,
validate_ipv6_route_gateway, validate_ipv6_target in validation.py) -
unlike the IPv4-or-IPv6 validators the IPv4 tools use, these explicitly
REJECT an IPv4 match, since /ipv6/* has no IPv4 concept and forwarding
one to the device would just surface RouterOS's own rejection instead of a
clear client-side error.
The "ipv6 package disabled" trap behaves DIFFERENTLY here than for the
v1.9 reads above: those catch DeviceCommandError and return []
(skip-if-missing). These six write tools deliberately do not - a write
has no safe "nothing happened" empty-list shape to fall back to, so if the
resolution step (reading existing rows to find the target rule/route/entry)
hits a disabled ipv6 package, DeviceCommandError propagates as a normal
write-tool error instead of being silently swallowed into a result that
would look identical to "there was nothing to toggle/remove".
remove_ipv6_route carries remove_route's single most important safety
property unchanged: it refuses outright to remove a dynamic route
(dynamic=true, via formatting.coerce_ros_bool - never a == "true"
string comparison). add_ipv6_route/remove_ipv6_route also carry the same
default-route warning their IPv4 counterparts carry, checked against
::/0 instead of 0.0.0.0/0.
Hardware caveat: not verified against real hardware - exercised only
against this project's fake RouterOS connection (tests/fakes.py), unlike
v1.9's reads (verified on both real ROS6 and ROS7). See CHANGELOG.md.
Dead-man / lockout-proof writes (v1.11)
No other MikroTik MCP server has this. It is this project's answer to a
problem every one of them shares: a write to a device reached over its own
radio link can cut the path back to that device, permanently, with no
remote recovery. ROADMAP.md's "Explicitly NOT on the roadmap" table
already names RouterOS's own best anti-lockout mechanism - Safe Mode - as
something this API-only, SSH-free project cannot use (it's a CLI-only,
interactive feature). The dead-man is the in-architecture answer: build the
equivalent of Safe Mode's "abandon changes if I disappear" guarantee purely
over the RouterOS API, using RouterOS's own scheduler as the timer.
The pattern, verified live against the single highest-risk case in this project's own fleet - an 8.8km PtP radio link that is the only management path to the far-end device:
Before a risky write, arm a scheduler local to the target device that reverts the change and removes itself once it fires - a ONE-SHOT schedule at an explicit future clock time, not a recurring
interval:/system scheduler add name=<name> start-date=<date> start-time=<time> \ interval=00:00:00 policy=read,write,test,policy,reboot \ on-event=":log warning DEADMAN; <revert-commands>; \ /system scheduler remove [find name=\"<name>\"]"<date>/<time>are computed asarm-time + N minutes, read from the TARGET DEVICE's own/system/clock- see "Design note" below for why this replaced an earlierinterval-only design.Apply the risky write.
If the write breaks reachability, the device heals itself once the deadline is reached - the on-event script runs entirely on-device, independent of whether the API session (or anything else) can still reach it afterward. Zero further action needed from the operator.
If the write is good, cancel the scheduler before it fires (
cancel_dead_man) - the change stays.
Two reusable primitives in guard.py, each going through the exact same
allowlist + read-only gate + confirm/preview + audit machinery as every
other guarded write here:
arm_dead_man(device_name, revert_commands, minutes=3, confirm=false)revert_commandsis any non-empty list (max 10) of RouterOS script statements that restore a known-good prior state.nameis always generated here ("deadman-<hex>", never caller-supplied) and returned as the handlecancel_dead_manneeds.minutesis capped 1-60.revert_commandsis a structurally-restricted channel, not a generic command-execution one: every item must match/<path> set [find <field>="<value>"] <field>=<value> ...- the exact "restore one row's fields" shape every internal caller already produces.setis the only verb ever accepted (neverremove/add/reboot/anything else);[find <field>="<value>"](a single selector with a balanced quote/bracket) is mandatory, never a bare.id. This is a positive allowlist, not a denylist (an earlier denylist-of-verbs version let/ip/route remove [find ...]-style commands and an unbalanced-quote parse-abort vector through - seedocs/api-notes-wireless-rf.mdfor the full account) - plus a fixed set of script-structure-breaking characters ($ ; \\ { }- not"/[/], which the[find name="X"]` idiom itself needs) and a redundant verb denylist as extra defense-in-depth layers underneath.
cancel_dead_man(device_name, name, confirm=false)- removes the scheduler before it fires.namemust match the"deadman-<hex>"shapearm_dead_manitself generates - by construction, before the device is ever read, this can never be pointed at an unrelated scheduler entry (e.g. an admin's own"backup-daily"task).
Not wireless-specific. set_wireless_channel/set_wireless_tx_power
(below) are this round's two callers - each arms a dead-man automatically
by default (arm_deadman=true) whose revert restores the interface's prior
frequency/channel-width or tx-power/tx-power-mode, read before anything
changes - but the primitive itself is generic: a future risky write in
another domain (a route, a bridge port, a firewall rule) can call
arm_dead_man directly with its own revert commands.
Auditing. arm_dead_man/cancel_dead_man are each independently
audited exactly like any other guarded write (see "Production hardening"
below) - a confirmed set_wireless_channel call that arms a dead-man
produces two journal entries (one for the channel change, one for the
arm), not one. The dead-man firing on-device is not something this
package's own JSON audit journal can observe (it happens autonomously,
outside any MCP tool call) - that is exactly what the on-event script's own
:log warning statement is for: it lands in RouterOS's own /log,
readable afterward via this package's logs/security_events tools.
Design note - one-shot (interval=00:00:00) with an explicit future
start-date/start-time, not an interval-only recurring schedule.
Verified live (and re-verified after an initial mistake - see
docs/api-notes-wireless-rf.md for the full account): an interval-only
schedule does not fire immediately on arm, it fires once per interval
starting at arm-time+interval, as expected. The real reason one-shot still
matters: RouterOS aborts an on-event script entirely at its first
unparseable statement, so a broken revert command means the trailing
/system scheduler remove never runs either. An interval-only schedule
in that situation stays armed and keeps re-firing the same broken
on-event every interval, indefinitely, until removed by hand; a one-shot
schedule fires exactly once no matter what happens inside it. The deadline
itself is computed with real datetime+timedelta arithmetic against the
TARGET DEVICE's own clock, so it is midnight/month/year-rollover safe.
See docs/api-notes-wireless-rf.md for the full write-up and the
real-hardware readings behind every number in this section.
Wireless RF tuning (v1.11)
Three guarded writes on /interface/wireless (RouterOS's legacy wireless
menu - see "Which menu, and why" in docs/api-notes-wireless-rf.md for why
this round targets it and not ROS7's newer /interface/wifi), plus one
read (get_wireless_link_quality, in the read-only table above) - built to
diagnose and tune a PtP/PtMP link, not just an access point's SSID
(set_wifi_ssid, unchanged, still the only /interface/wifi write tool):
set_wireless_channel(interface_name, frequency, channel_width=None, ...)LOCKOUT-RISK: arms a dead-man by default (see above). The preview's
warningalways reports whether the target frequency needs a DFS Channel Availability Check under the interface's currentfrequency-mode, read from the device: instant withfrequency-mode=superchannel(verified - no DFS/CAC at all), ~60s for most of the DFS range (5250-5725MHz), ~600s for the 5600-5650MHz weather-radar sub-band specifically.
set_wireless_tx_power(interface_name, tx_power, ...)- forcestx-power-mode=all-rates-fixed. LOCKOUT-RISK, same dead-man default. Verified today: on a short link, the default (maximum) tx-power saturated the receiver (-27dBm measured) and produced a worse CCQ (34) than a lower power (~8dBm gave -47dBm and CCQ 94). There is no single "right" power - useget_wireless_link_qualitybefore/after to judge the effect on a given link. A brief CCQ/rate dip right after applying is expected (rate re-adaptation), not a failure.set_wireless_tuning(interface_name, adaptive_noise_immunity=None, distance=None, arm_deadman=True, deadman_minutes=3, ...)adaptive_noise_immunityalone is reception-only tuning, confirmed safe (does not drop an already-associated link) - never arms a dead-man. Verified today:"ap-and-client-mode"measurably helps when signal is good but CCQ is poor (interference, not distance).A numeric
distance(unlike the named"dynamic"/"indoors"modes) directly sets the ACK-timeout/TDMA timing - LOCKOUT-RISK, confirmed this can silently drop an already-associated link on a mismatch. Arms a dead-man by default, same mechanism asset_wireless_channel/set_wireless_tx_power. Verified today: an explicitdistance(e.g.9for a ~9km link) beats"dynamic"for ACK timeout on a long PtP link.
All three: errors if interface_name doesn't exist on /interface/wireless
never creates one.
get_wireless_link_qualitynormalizes the same registration-tablewireless_registrationsalready reads intosignal_strength/signal_to_noise/tx_ccq/rx_ccq/tx_rate/rx_rate/distance/uptimeper peer - the numbers that actually diagnose a PtP link, rather than a raw, generation-dependent row shape.
VPN & routing diagnostics (v0.8)
Six read-only tools for VPN, routing-protocol, and failover diagnostics
none of them touch the write guard or require
MIKROTIK_ALLOW_WRITE:VPN sessions/peers:
wireguard_peers(WireGuard),ppp_active(PPP-based VPN servers - l2tp/pptp/sstp/ovpn/pppoe),ipsec_active_peers(IPsec). Each covers a different RouterOS VPN mechanism; a device that doesn't use a given one returns an empty list, not an error.wireguard_peersnever returns a private key, even defensively - see "Security model" below.Routing-protocol status:
bgp_sessions(tries ROS7's/routing/bgp/sessionfirst, falls back to ROS6's/routing/bgp/peer- the same generation splitwireless_registrationsalready handles for wifi) andospf_neighbors. Empty list (not an error) for a device that doesn't run the protocol.netwatch:/tool/netwatch, RouterOS's own mechanism for watching a gateway or peer's reachability (commonly used to drive an up/down scripte.g. flipping a failover route). This is the key diagnostic read for understanding whether/how a device's own failover behavior is configured. The
up-script/down-scriptfields are surfaced only ashas-up-script/has-down-scriptpresence booleans, never the raw script body - a Netwatch script can contain arbitrary RouterOS commands (route changes, credential changes, ...) that don't belong in a read tool's output.
These six were the read-only foundation v0.8 shipped for failover tooling; v0.9 (below) adds the corresponding write tools.
Failover control (v0.9)
Five guarded write tools - set_route_distance, enable_route/
disable_route, add_netwatch/remove_netwatch - are the atomic
building blocks for adjusting a RouterOS failover setup. Deliberately
small, composable steps, not one black-box "do a failover" command: an
LLM caller (or a human operator) combines them, previewing each one before
applying it. add_route/remove_route (v1.5, below) extend the same
/ip/route family with the two writes that were still missing: creating
and removing a route outright, rather than only adjusting one already on
the device.
Recommended flow:
netwatch(read) andip_routes(read) to see the current setup - which gateways are being watched, and which routes/distances currently determine which one wins.add_netwatch(confirm=falsefirst to preview, thenconfirm=true) to start watching a gateway/peer's reachability, if not already monitored. This tool never accepts an up-script/down-script parameter (see below) - it only creates the observable host/status/interval/comment row. RouterOS's own up/down-script mechanism (configured manually, out-of-band, on the device - WinBox/CLI) is what actually reacts to a Netwatch status change; this package deliberately does not create or modify a script for you, e.g. by generating one that callsset_route_distance/disable_routeon transition - see "Netwatch scripts are never accepted" below for why.To actually switch which route wins, either:
set_route_distance(preview, then confirm) to change a route's priority (lowerdistancewins) relative to another route with the samedst-address- the non-destructive way to fail over: both routes stay present and enabled, only their relative priority changes.disable_route/enable_route(preview, then confirm) to take a route out of/back into consideration entirely.
ip_routes(read) again afterward to confirm the routing table now reflects what you intended.
RISK - the default route. disable_route's returned preview carries a
non-null warning field whenever the route being disabled is the default
route (dst-address = 0.0.0.0/0 or ::/0) - disabling it cuts all
outbound traffic that relies on that gateway, not just traffic to one
destination. This is set on both the confirm=false preview and the
confirm=true applied result, so a caller reading only applied/after
still cannot miss it. Always read the warning field before calling again
with confirm=true - and prefer set_route_distance over
disable_route/enable_route for the default route specifically when
possible: changing which of two already-enabled default routes has the
lower distance fails over without ever leaving the device with zero
enabled default routes at any point in between.
Route resolution: stable identifiers, never an index. All three route
tools resolve the target row by its dst-address, narrowed by gateway
and/or comment when more than one route shares that dst-address -
exactly the failover shape (e.g. two 0.0.0.0/0 routes to different
gateways). Never by a RouterOS .id (reassigned as routes are added/
removed elsewhere on the device) or a list index (even less stable). If
nothing matches, ResourceNotFoundError. If more than one route still
matches after narrowing, AmbiguousResourceError - the tool never guesses;
the caller must add (or correct) gateway/comment.
Netwatch resolution: same rigor as routes. remove_netwatch resolves
its target by host (tried first if both are given), falling back to
comment, exactly like the route tools above - never a RouterOS .id.
add_netwatch itself refuses to create a second monitor for a host that
already has one, but a device can still end up with more than one row
sharing a host/comment via manual (WinBox/CLI) configuration outside
this tool; if so, remove_netwatch raises AmbiguousResourceError instead
of removing the first match - never a silent guess.
Netwatch scripts are never accepted. add_netwatch has no
up_script/down_script parameter at all - not validated-and-rejected,
genuinely absent from the tool's signature - because a Netwatch script body
can run arbitrary RouterOS commands (route changes, credential changes,
...), exactly the class of caller-controlled-arbitrary-command vector this
package's write guard exists to rule out (see "Security model" below and
guard.py's module docstring). Configure up/down scripts manually on the
device (WinBox/CLI) once the monitor exists. The read-only netwatch tool
already only ever surfaces has-up-script/has-down-script as presence
booleans, never a script body, for the same reason.
Static route add/remove (v1.5)
add_route/remove_route close out the route family started in v0.9,
reusing the same _resolve_route machinery set_route_distance/
enable_route/disable_route already use - a route is always identified
by its stable dst-address (+ optional gateway, and for add_route's
disambiguating siblings comment), never a RouterOS .id/list index.
The default-route warning fires in both directions. Exactly like
disable_route, both add_route and remove_route set a non-null
warning field - on both the confirm=false preview and the
confirm=true applied result - whenever dst_address is the default route
(0.0.0.0/0/::/0): adding/overriding it redirects all outbound traffic
through the new gateway; removing it cuts outbound traffic that relies on
the old one. Always read warning before calling again with confirm=true.
add_route never refuses a duplicate dst_address. Unlike
add_vlan/add_static_dns, two (or more) routes sharing a dst-address
is the normal failover shape - a second 0.0.0.0/0 pointing at a backup
gateway is exactly what this tool is for, so it never raises
ResourceAlreadyExistsError.
SAFETY GUARANTEE - remove_route refuses to remove a dynamic route,
outright. If the resolved row's dynamic field is "true" (a
connected/DHCP/OSPF/BGP-installed route - RouterOS creates these itself,
an operator did not), remove_route raises an error before building any
preview and without ever calling the write primitive - this is a hard
refusal, not merely a warning (contrast remove_dhcp_lease, which warns
but still allows removing a static lease). Removing a device's connected/
dynamic route can sever the network entirely, so this tool only ever
manages static, admin-created routes; if removing a dynamic route is
genuinely intended, do it manually on the device (WinBox/CLI).
DNS management (v0.10)
add_static_dns/remove_static_dns manage /ip/dns/static - typical uses:
Block a malicious/unwanted domain:
add_static_dnswithaddressset to0.0.0.0(or another sinkhole address) - any client that resolves the blockednamethrough this device's DNS server gets that address instead of the real one.Internal DNS override: point an internal hostname at a specific internal IP, or (
record_type="CNAME") alias one hostname to another.
record_type selects what address means: "A" (default) is a literal
IPv4/IPv6 address; "CNAME" means address is itself another hostname (the
alias target), written to RouterOS's cname field - a CNAME row has no
address field of its own on the device.
Resolution: name+record_type, never a dynamic index. add_static_dns
refuses to create a second row for the same name+record_type pair
(ResourceAlreadyExistsError) - this also means RouterOS round-robin DNS
(two "A" records sharing a name but pointing at different addresses) is
not something this tool creates; add the second record manually on the
device if that's genuinely intended. remove_static_dns resolves by name,
narrowed by record_type if given; if more than one row still matches after
narrowing (e.g. an existing round-robin pair), AmbiguousResourceError -
the tool never guesses which one to remove.
clear_dns_cache (/ip/dns/cache/flush) is unrelated to /ip/dns/static -
it clears cached upstream DNS answers, not any configured entry. Benign
(cached answers repopulate on the next resolution) but still a guarded,
confirm-gated write, like every other tool here.
DHCP lease removal (v0.10)
remove_dhcp_lease removes an existing /ip/dhcp-server/lease row by
address or mac_address (mac_address is tried first if both are given -
the more stable identifier, since an address can be reused by a different
lease over time). The typical use is forcing a client to renew its IP: the
existing lease is deleted, and the client is offered a new one on its next
DHCP exchange.
This removes EITHER a dynamic or a static lease. RouterOS's dynamic
field on the resolved row tells them apart. Removing a DYNAMIC lease is the
ordinary case above. Removing a STATIC lease (one pinned by
add_static_dhcp_lease) is also allowed - not blocked outright - but it
deletes the pinned IP↔MAC mapping itself, not just a renewable entry, so the
returned preview's warning field is non-null whenever the resolved lease
is static, on both the confirm=false preview and the confirm=true
applied result. Always check warning before calling again with
confirm=true.
Wake-on-LAN (v0.10)
wake_on_lan sends a /tool/wol magic packet for mac_address, out
interface. Unlike every other write tool in this package, there is
nothing existing on the device to resolve or verify first - mac_address/
interface are validated for shape only (this does NOT check that
interface exists on the device; RouterOS itself rejects an unknown
interface name at send time). Benign - it never changes device
configuration - but still guarded/confirm-gated like every other write
tool, so an LLM caller can't wake a machine "by accident".
Firewall rule toggle (by comment) (v0.11)
Creating or generally modifying a firewall filter rule stays out of scope
(see "Roadmap / non-goals" below): a single wrong rule - e.g. one that
blocks the API port itself - can lock out all remote management access to
the device, with no way to recover it over the same connection. That risk
can't be designed away from inside the tool itself, so instead of exposing
rule authorship, v0.11 exposes only the narrowest safe operation on top of
an existing rule: flipping its disabled field.
The intended workflow (the community-suggested design this round follows): an admin creates a rule ahead of time, on the device itself, reviews it once, and leaves it disabled -
/ip firewall filter add chain=forward src-address-list=attacker-x \
action=drop comment="Bloqueio_Ataque_X" disabled=yesand an LLM caller later enables it via
enable_firewall_rulewhen it detects the condition the rule exists to guard against:
enable_firewall_rule(device_name="core-switch", comment="Bloqueio_Ataque_X", confirm=false) # preview
enable_firewall_rule(device_name="core-switch", comment="Bloqueio_Ataque_X", confirm=true) # applyIf something goes wrong, the admin knows exactly which rule was toggled -
the same one they already wrote and reviewed, never a rule this package
authored on its own judgment. disable_firewall_rule reverses it the same
way.
Resolution: comment, never a dynamic index. Both tools resolve the
target rule by its comment - a STABLE, admin-controlled identifier -
optionally narrowed by chain if two rules share the same comment on
different chains. A comment that matches no rule raises
ResourceNotFoundError (never falls back to creating one); a comment
that still matches more than one rule after narrowing raises
AmbiguousResourceError - the tool never guesses which one to toggle. The
returned preview's before/after are the full matched rule (every
field RouterOS returned for it - chain/action/etc, not just
disabled), so the caller can confirm WHICH rule this is before ever
passing confirm=true.
NAT & mangle rule toggle (by comment) (v1.4)
enable_nat_rule/disable_nat_rule and enable_mangle_rule/
disable_mangle_rule extend the exact pattern above to the other two
firewall menus: /ip/firewall/nat (already read-only since v0.4 via
firewall_nat) and /ip/firewall/mangle (read-only tool firewall_mangle
added this same round). Same reasoning, same guarantees, same resolution:
Never creates a rule. An admin creates the NAT/mangle rule ahead of time, on the device itself, reviews it once, and leaves it disabled; an LLM caller later enables it via
enable_nat_rule/enable_mangle_rulewhen it detects the condition the rule exists to guard against - e.g. a port-forward left disabled until a maintenance window, or a traffic-mark rule staged for a QoS change. Why this matters just as much here as for filter: disabling the wrong NAT rule can cut a whole LAN's Internet access (e.g. thesrcnat/masquerade rule), and mangle rules commonly feed routing/QoS decisions downstream - creation/free-form edit stays out of scope for the same lockout reasoning "Firewall rule toggle (by comment)" above explains for filter (seeROADMAP.md's "Explicitly NOT on the roadmap").Resolution:
comment, never a dynamic index, optionally narrowed bychain(NAT:srcnat/dstnat; mangle:prerouting/postrouting/forward/input/output, or a custom jump-target chain on either menu -chainis shape-only, not a fixed enum, same as filter's). Acommentmatching no rule raisesResourceNotFoundError; one still matching more than one rule after narrowing raisesAmbiguousResourceError- never guesses which one to toggle.Implementation note: both pairs share the exact same
guard.pyhelper the filter pair (v0.11) already used (_set_firewall_rule_disabled), generalized with aresource_labelparameter soResourceNotFoundError/AmbiguousResourceErrorname the right menu ("Firewall filter rule" / "Firewall NAT rule" / "Firewall mangle rule") - the resolution logic itself was already path-agnostic, so nothing about it changed for filter.
enable_nat_rule(device_name="core-switch", comment="rdp-forward-maintenance", confirm=false) # preview
enable_nat_rule(device_name="core-switch", comment="rdp-forward-maintenance", confirm=true) # applyConnection tracking (filtered) (v0.11)
connection_tracking reads RouterOS's connection tracking table
(/ip/firewall/connection) - useful to see what a client is actually
talking to right now, e.g. while investigating the traffic a
set_client_bandwidth/add_to_address_list decision was based on.
A filter is mandatory - on a production router, the full table can be
large enough to blow straight past an LLM caller's context/token budget on
its own (a community-reported gotcha this tool is built to avoid). Calling
it with none of src_address/dst_address/dst_port/protocol set raises
a ValidationError instead of returning everything.
The result is also hard-capped at 100 rows regardless of how many match:
truncated is true whenever more rows matched than were returned, and
total_matched always reports the real, pre-truncation count - so a caller
always knows whether it's seeing everything or should narrow the filter
further.
Each returned entry: protocol, src-address/src-port and
dst-address/dst-port (RouterOS packs address+port into one field, e.g.
"192.0.2.1:80" - this tool splits them apart), tcp-state (populated for
TCP connections), timeout, and the assured/confirmed/seen-reply
flags - RouterOS's own closest equivalent to a generic "connection state"
for this table.
Security audit (v0.12)
Two read-only tools, both in src/mcp_mikrotik/security.py, built for
the "analyze this router's security" use case: an LLM caller reads config
and recent logs and reports what it sees, rather than an operator manually
walking every menu.
security_audit(device_name) runs eight independent, defensive checks
and returns {"findings": [{"severity", "category", "title", "detail", "recommendation"}, ...], "summary": {"high", "medium", "low", "info"}} -
findings sorted by severity (high first), summary always including all
four keys (0 for a severity with no findings):
Insecure management services (
/ip/service):telnet/ftp/www/apienabled (cleartext/non-SSL protocols) -highiftelnet/ftpis also open to any address (addressempty or0.0.0.0/0),mediumifwww/apiis,lowif enabled but restricted to a narrower range.winboxenabled and open to any address is its ownmediumfinding.ssh/api-ssl/www-sslare never flagged - they're the secure counterparts a caller is expected to prefer.Firewall input chain has no final drop/reject (
/ip/firewall/filterchain=input): a conservative heuristic based on rule order alone - if the LAST enabled rule on chain=input isn'taction=drop/reject(or there are no enabled input rules at all), amediumfinding recommends reviewing whether unmatched management traffic is actually blocked. This does not claim certainty - RouterOS's real evaluation semantics (jump chains, address-list matches, etc.) are richer than one rule's position can prove; seesecurity.py's_check_firewall_input_dropdocstring.SNMP community open (
/snmp/community): a community namedpublic(RouterOS's default) or with noaddressesrestriction (empty/0.0.0.0/0) -medium.DNS resolver open to remote requests (
/ip/dnsallow-remote-requests=yes) -medium; can be abused for DNS amplification/reflection if not also restricted by the firewall.RouterOS outdated (
/system/package/update): installed version differs from the latest available -low. Skipped (no finding) if the device hasn't checked for updates yet.Open wireless/wifi (no security at all): ROS6
/interface/wireless/security-profileswithmode=none, or ROS7/interface/wifi/securitywith no passphrase AND noauthentication-typesconfigured (802.1X/EAP setups legitimately have no passphrase, so only BOTH absent counts) -high.Users with a write/full policy (
/user): aninfofinding counting how many configured accounts have awrite/fullgroup - visibility, not a vulnerability by itself.Certificate expired or expiring soon (
/certificate, v1.6):highif a certificate is expired,mediumif it expires within 30 days. Trusts RouterOS's ownexpiredflag (viacoerce_ros_bool) OR a negativedaysUntilExpirycomputed frominvalid-after- either is sufficient on its own, so a device that only reliably exposes one of the two still gets a correct answer; a certificate with neither available contributes no finding. See "AAA/PKI visibility" below.
Each check is defensive: it reads its own menu(s) and, if that menu
doesn't exist on this device/RouterOS generation (DeviceCommandError),
contributes no findings instead of failing - the same "empty/skipped, not
an error" convention system_health/poe_status/wireless_registrations
already use. One check being unavailable never stops the rest of the audit.
NEVER a scanner, NEVER definitive. Every check here is a best-effort read of a handful of RouterOS menus - it can both under-report (a real misconfiguration this module doesn't know to look for) and, for check #2 specifically, over-report on an unusual-but-intentional ruleset. Findings exist to prompt a human/LLM review, not to be treated as ground truth.
No finding ever contains a secret. /ip/service, /snmp/community,
/interface/wireless/security-profiles, /interface/wifi/security, and
/user can all carry a password/passphrase/community-string-shaped field -
no check ever copies a raw row (or a credential field from one) into a
finding; each finding's text is built from a fixed template referencing
only non-secret fields (name, mode, address restriction, boolean presence
checks, counts). See tests/test_security.py's
test_run_security_audit_never_leaks_a_secret (unit-level, every
secret-bearing menu populated with a distinctive marker value while
multiple checks are made to fire) and test_server.py's
test_security_audit_* (the same guarantee exercised through the actual
MCP tool call).
security_events(device_name, limit=50) filters /log down to
security-relevant entries: topic account (RouterOS's own topic for
login/logout/authentication-failure events), critical/error topics, and
a generic system,info entry whose message looks like a login/logout.
Filtering happens client-side (the same reasoning logs' topics filter
already documents) and is applied BEFORE the limit cut, so a caller
always gets the most recent limit MATCHING entries - limit is capped at
500, same shape as logs' own limit. Useful to correlate access
attempts/anomalies without reading the entire (often much larger)
unfiltered log via logs.
Both tools are read-only - neither is gated by MIKROTIK_ALLOW_WRITE,
and neither changes anything on the device.
AAA/PKI visibility (v1.6)
Four read-only tools closing out most of ROADMAP.md's Tier 2 -
certificate expiry, users/AAA, and RADIUS - all in src/mcp_mikrotik/server.py.
certificates(device_name) lists /certificate: name, common-name,
subject/issuer fields (if present), invalid-before/invalid-after (raw
RouterOS date strings), key-size/key-type, fingerprint, and RouterOS's own
expired/trusted flags returned as-is (use formatting.coerce_ros_bool
if you need to branch on them rather than just display them).
A computed daysUntilExpiry (negative once past due) is added from
invalid-after whenever it can be parsed. RouterOS's own date rendering
varies by version/locale - formatting.parse_ros_datetime handles the two
confirmed shapes ("2027-01-15 12:00:00" and "jan/15/2027 12:00:00", the
latter matched against a fixed English month-abbreviation table rather than
strptime's locale-dependent %b, since RouterOS always renders it in
English regardless of the server process's own locale) and is DEFENSIVE BY
DESIGN: an unrecognized/unparseable date never raises - daysUntilExpiry is
simply omitted and the raw invalid-after string is left untouched.
SECURITY: /certificate's own API reply never carries a private key
(RouterOS only returns certificate metadata over the API) - a private-key
field is nonetheless stripped defensively before returning
(strip_sensitive_fields), the same mechanism ppp_secrets/
wireguard_interfaces already use, in case a future RouterOS version or
firmware quirk ever adds one.
security_audit's check #8 (see "Security audit" above) uses this exact
same expiry logic (formatting.days_until) to flag an expired or
soon-to-expire certificate as a finding, so the read tool and the audit
check can never disagree about what "expiring soon" means.
users(device_name) lists /user: name, group, address (an
allowed-source restriction, if the account has one), last-logged-in (if
RouterOS exposes it), disabled, comment. /user's own API reply never
carries a password at all - RouterOS doesn't expose it over the API - so
there's nothing to strip here, unlike ppp_secrets/radius. This is a READ
only: creating or editing a /user login stays deliberately out of scope
for this package - see "Roadmap & non-goals" below for why a router login
is a different risk class from a service credential like a PPP secret or
hotspot user.
user_active(device_name) lists /user/active: name, address,
via (e.g. api/winbox/ssh/web), when - who is currently logged
into the device's own management right now, as opposed to users'
CONFIGURED accounts.
radius(device_name) lists /radius: service, address, timeout,
accounting-port, authentication-port, and whatever other fields
RouterOS returns for a given entry.
SECURITY: RouterOS's own /radius reply carries the plaintext shared
secret - this is ALWAYS stripped before returning
(strip_sensitive_fields), the exact mechanism ppp_secrets uses for
/ppp/secret's password and wireguard_interfaces uses for a tunnel
interface's private-key. A RADIUS shared secret never leaves this process
via this tool. See tests/test_server.py's test_radius_never_exposes_secret.
All four tools return an empty list (never an error) if their menu is unavailable on a given device/RouterOS generation - the same convention every other optional read in this package uses.
WireGuard management (v0.13)
The most security-sensitive round in this package's history: WireGuard uses private keys. The absolute rule this round is built around: no tool, error message, preview, or audit journal entry may ever contain one. Private keys stay on the router - period.
Four tools, all covering /interface/wireguard and
/interface/wireguard/peers:
wireguard_interfaces(read) - lists tunnel interfaces (name, listen-port, public-key, running, disabled, mtu). RouterOS's own reply for this menu genuinely carries aprivate-keyfield - always stripped before returning.wireguard_peers(v0.8) is unchanged in shape but now also strips a peer'spreshared-key(a real, optional field on that menu) the same way.add_wireguard_interface(write, guarded) - creates a tunnel interface (name, optionallisten_port). RouterOS generates the private key internally when the interface is created - there is noprivate_keyparameter on this tool at all, so there is no code path through which a caller could ever supply (or receive back) one. Theconfirm=falsepreview'safteronly describes what will be created (name/listen-port) - it never invents apublic-key, since RouterOS hasn't generated the key pair yet at preview time. Only theconfirm=trueapplied result re-reads the newly created interface and reports its realpublic-key(safe to share - it's what a remote peer needs to connect to you), withprivate-keyalways stripped. Refuses to create a second interface sharingname.add_wireguard_peer(write, guarded) - registers a remote peer on an existinginterface: itspublic_key(validated as a 44-character base64 WireGuard key),allowed_address(a comma-separated list of CIDR ranges, e.g."10.0.0.2/32,10.0.0.3/32"), and optionalendpoint_address/endpoint_port/persistent_keepalive/comment. Has noprivate_keyorpreshared_keyparameter either - the remote peer's own private key (and any preshared key) are entirely out of this tool's scope.interfacemust already exist (create it first withadd_wireguard_interface); refuses to add a duplicate peer (samepublic_keyalready registered on the sameinterface).remove_wireguard_peer(write, guarded) - removes a peer from aninterface, resolved bypublic_keyorcomment. Errors if more than one peer still matches after narrowing (AmbiguousResourceError) - never guesses which one to remove.
Typical flow to stand up a site-to-site or road-warrior tunnel:
add_wireguard_interface(confirm=falseto preview, thenconfirm=true) to create the tunnel interface on this device.wireguard_interfacesto read back its realpublic-key- give that to the remote peer, out of band, so it can configure its own side.add_wireguard_peer(confirm=falsethenconfirm=true) to register the remote side'spublic_keyand the traffic (allowed_address) routed through it.wireguard_peersto checklast-handshake/rx/tx counters once the remote side connects, confirming the tunnel is actually passing traffic.remove_wireguard_peerto revoke a peer later (e.g. a decommissioned site or a compromised key).
How the private-key/preshared-key redaction is enforced (belt-and-suspenders, two independent layers):
formatting.strip_sensitive_fields(withformatting.WIREGUARD_SENSITIVE_FIELDS = {"private-key", "preshared-key"}) is applied to every row these tools ever return - both read tools (wireguard_interfaces/wireguard_peers, inserver.py) and every write tool's before/after preview (inguard.py's_redact_wireguard_row, applied before aWritePreviewis ever constructed - see next point for why that ordering matters).The audit journal never gets a chance to see one either. The write-guard's
_auditeddecorator (v0.5) journals exactly whatever aguard.pyfunction returns - so if redaction happened only inserver.py(one layer up, afterguard.pyreturns), a private-key would already be sitting in the audit journal (a file on disk, or a log line) by the timeserver.pygot a chance to strip it. Every WireGuard write function inguard.pytherefore redacts its ownbefore/afterbefore constructing theWritePreviewthe decorator will log.audit._SENSITIVE_KEY(extended this round to also matchprivate, on top of the existingpre.?sharedterm that already coverspreshared-key) is a second, independent line of defense on top of that, not a substitute for it.
See tests/test_guard.py's WireGuard section, tests/test_guard_audit.py's
test_add_wireguard_interface_confirmed_call_never_leaks_private_key_into_journal,
and tests/test_server.py's
test_add_wireguard_interface_never_leaks_private_key_anywhere for the tests
proving all of this: a fake device (tests/fakes.py) simulates RouterOS
generating a real key pair - including a distinctively-marked private-key -
on interface creation, and every test asserts that marker never reaches the
tool's return value, the audit journal (before and after), or any
other log line.
Hotspot vouchers (v0.14)
hotspot_active (read) lists who's currently logged into the RouterOS
hotspot (/ip/hotspot/active) - user, address, mac-address, uptime,
bytes-in/bytes-out. add_hotspot_user (write, guarded) creates a new
voucher - a visitor login, not a device/API credential:
add_hotspot_user(name="visitor-42", password="Xk7mQ2p9", limit_uptime="02:00:00")profile (an existing /ip/hotspot/user/profile name, e.g. to cap shared
bandwidth), limit_uptime (a RouterOS duration), and limit_bytes_total (a
positive integer byte quota) are all optional. Refuses to create a
duplicate name - it never resets an existing voucher's password.
QR/voucher payload. The tool result always also includes username,
password, and qr_payload - a plain string the caller renders as a QR
code itself (this package deliberately does not generate a QR image -
that would mean pulling in an imaging dependency for something a caller's
own UI layer can do in a couple of lines). The format chosen for
qr_payload is "<username>:<password>" - a plain, self-describing
credential pair. Two alternatives were considered and rejected:
A login URL (
http://<hotspot>/login?username=..&password=..) would need a reliably-known hotspot LAN address, which this package has no way to determine for an arbitrary device/deployment - and RouterOS's actual captive-portal login is normally a POST carrying additional session-specific tokens (chap-id/chap-challenge), so a bare GET URL with a plaintext password wouldn't even reliably authenticate.A
WIFI:T:WPA;S:<ssid>;P:<pass>;;payload is for auto-joining a WPA wireless network - a different credential (and a different protocol layer) than a hotspot LOGIN (walled-garden HTTP auth), which can just as easily run over a wired port.
username:password makes no claim about network topology or login
mechanics that could turn out to be wrong for a given deployment. A caller
integrating with a specific captive portal is free to build its own login
URL (or its own QR format) from username/password plus its own
known portal address.
The deliberate password asymmetry. Unlike every secret this package has
handled before (a device password, a WireGuard private-key - never returned
to any caller at all), a voucher's plaintext password is present in
the tool's own result on both confirm=false (preview) and confirm=true
(applied) - the caller needs it to hand to a visitor. It must still never
reach the audit journal. No new redaction code was needed for that:
audit._SENSITIVE_KEY already matches "password" case-insensitively at
any depth of the journaled {"before": ..., "after": ...} summary (it's
what already protects a device's own connection password - see the "Audit
journal" bullet under "Production features" below), so the exact same
after dict returned to the caller gets its password key silently
dropped before audit.record() ever sees it. See
tests/test_guard_audit.py's
test_add_hotspot_user_password_never_in_audit_journal and
tests/test_server.py's
test_add_hotspot_user_password_in_result_but_never_in_audit_journal for
the proof, at both the guard layer and the full MCP tool-call boundary.
Live traffic monitoring (torch) (v0.14)
torch answers "who is consuming bandwidth on this interface right
now" - a single live snapshot via RouterOS's own real-time traffic
monitor (/tool/torch interface=<interface> once=yes), built the same
"once" monitor-style way as interface_traffic/poe_status/lte_status.
interface is validated for shape before use; optional src_address/
dst_address/port filters are forwarded to RouterOS itself, narrowing
the snapshot before it ever leaves the device - use them on a busy
interface instead of fetching every flow and filtering client-side.
Regardless of how many flows RouterOS reports for the requested instant,
the result's flows list is sorted by total traffic (tx+rx, biggest
talkers first) and hard-capped at 50 entries - truncated is true
whenever more flows matched than were returned, and total_matched always
reports the real (pre-cap) count, the same "cap it, tell the caller how
much was cut" shape connection_tracking (v0.11) already established.
Diagnostic only - torch never changes device state, so (like ping/
traceroute) it is not gated by MIKROTIK_ALLOW_WRITE.
Backup (v0.14)
create_backup (write, guarded) creates a RouterOS system backup file
(/system/backup/save name=<name>) - the device's full configuration
(interfaces, firewall, users, ...) as one binary .backup file on its own
storage. list_backups (read) lists existing ones - use it after
create_backup to confirm a new backup landed and see its real
size/creation-time.
create_backup refuses to overwrite an existing .backup file of the same
name (ResourceAlreadyExistsError) - RouterOS's own /system/backup/save
would otherwise silently overwrite one. An optional password encrypts the
backup file itself (unrelated to any device/API credential) - it is
redacted before the write's preview is ever constructed (the same
"redact before constructing the preview" rule v0.13's WireGuard round
established for private/preshared keys), so it never reaches the caller or
the audit journal, either.
Backup restore is deliberately not exposed - see "Roadmap / non-goals" below: loading a backup overwrites a device's entire running configuration and reboots it, the same risk class as a remote reboot, with no meaningful before/after preview and no rollback if the wrong file (or the right file, at the wrong time) is loaded.
VLAN management (v1.2)
list_vlans (read) lists /interface/vlan rows, excluding disabled ones by
default - same include_disabled convention as interfaces. add_vlan
(write, guarded) creates one: name (the new RouterOS interface name, e.g.
"vlan100"), vlan_id (1-4094, the IEEE 802.1Q tag), and interface (the
parent it rides on top of, e.g. "bridge1"/"ether2" - not verified to
exist here; RouterOS itself rejects an unknown parent at write time).
mtu/comment are optional. It refuses to create a duplicate name
(ResourceAlreadyExistsError) rather than silently reconfiguring an
existing VLAN interface. remove_vlan (write, guarded) removes one by
name, erroring (ResourceNotFoundError) if it doesn't exist.
add_vlan(device_name="core-switch", name="vlan100", vlan_id=100, interface="bridge1", confirm=false) # preview
add_vlan(device_name="core-switch", name="vlan100", vlan_id=100, interface="bridge1", confirm=true) # applyFirewall rule reorder (by comment) (v1.2)
move_firewall_rule (write, guarded) reorders an existing
/ip/firewall/filter rule - it never creates or otherwise edits a rule's
fields (chain/action/etc); only its position in the chain's evaluation
order changes. Same philosophy as enable_firewall_rule/
disable_firewall_rule (v0.11) above: authoring or generally modifying a
firewall rule stays out of scope (a wrong rule can lock out remote
management with no way to recover it over the same connection), so this
only exposes the narrow, safe operation of moving an admin-authored rule
that already exists.
Resolution: comment, never a dynamic index - the same STABLE,
admin-controlled identifier enable_firewall_rule/disable_firewall_rule
resolve a rule by, optionally narrowed by chain if more than one rule
shares that comment. A comment that matches no rule raises
ResourceNotFoundError; one that still matches more than one rule after
narrowing raises AmbiguousResourceError - never guesses which one to
move.
Destination: exactly one of two ways to say where. before_comment
moves the rule to appear immediately before an existing rule identified by
its own comment (resolved the same way, and also subject to
AmbiguousResourceError if it isn't unique); position moves it to a
0-based index among the other rules (i.e. after the rule being moved is
taken out of consideration) - a position at or beyond the end of that
list moves it to the very end. Supplying both, or neither, raises
ValidationError.
move_firewall_rule(device_name="core-switch", comment="Bloqueio_Ataque_X", position=0, confirm=false) # preview
move_firewall_rule(device_name="core-switch", comment="Bloqueio_Ataque_X", position=0, confirm=true) # applyThe returned preview's before/after report the rule's comment/chain
plus its current vs. new position - not the full row (unlike
enable_firewall_rule/disable_firewall_rule's preview), since no field on
the rule itself is changing, only where it sits in the list.
PPP/PPPoE secrets (v1.3)
ppp_secrets (read) lists /ppp/secret rows - the CONFIGURED dial-in
credentials (name, service, profile, remote-address, local-address,
disabled, comment, last-logged-out) - as opposed to the pre-existing
ppp_active, which lists currently-CONNECTED sessions. add_ppp_secret
(write, guarded) creates a new one - a dial-in service credential, not a
device/API login:
add_ppp_secret(name="fiber-customer-42", password="Xk7mQ2p9", service="pppoe", profile="default-encryption")service (default "any") restricts which PPP service the secret may
authenticate for - one of "pppoe"/"pptp"/"l2tp"/"ovpn"/"sstp"/
"any". profile (an existing /ppp/profile name, e.g. to assign an
address pool or rate limit) and remote_address (a literal IP handed to the
client on connect) and comment are all optional. Refuses to create a
duplicate name. remove_ppp_secret (write, guarded) removes one by
name, erroring (ResourceNotFoundError) if it doesn't exist, or
(AmbiguousResourceError) if more than one row somehow shares that name -
never guessing which one to remove.
Same risk class, same precedent as add_hotspot_user (v0.14). A
/ppp/secret only grants network/dial-in access - it can never touch the
router's own configuration, unlike a /user login (which is deliberately
not on this package's roadmap - see "Roadmap & non-goals" below). So
add_ppp_secret follows add_hotspot_user's password handling exactly: the
plaintext password DELIBERATELY appears in this tool's own result (the
caller supplied it and gets it echoed back as confirmation of exactly what
was written), but never reaches the audit journal - audit._SENSITIVE_KEY
already matches "password", so no new redaction code was needed. See
tests/test_guard_audit.py's test_add_ppp_secret_password_never_in_audit_journal
for the proof.
remove_ppp_secret redacts the opposite way: it looks up an EXISTING row
first, and that row's own password field is stripped in guard.py -
before the returned preview is ever constructed - so it can never leak
into the tool's result or the audit journal either. See
tests/test_guard.py's test_remove_ppp_secret_preview_never_includes_password
for the proof.
ppp_secrets never returns a password field at all, the same
formatting.strip_sensitive_fields mechanism wireguard_interfaces uses
for a tunnel interface's private-key.
Security model
This section is the single consolidated reference for every control this package applies - to every read, and especially to every write. The philosophy is simple: read-only by default, writes are opt-in, allowlisted, previewed, and audited. No tool in this package ever accepts an arbitrary RouterOS API path or a free-form command.
Three independent controls apply to every write tool, all centralized in
src/mcp_mikrotik/guard.py:
Read-only by default.
MIKROTIK_ALLOW_WRITEdefaults tofalse. With writes disabled, any write tool returns a clear error and never touches the device - the gate is checked before any read or write call is made.Central allowlist, no generic command tool. There is no tool that accepts an arbitrary API path or command. Each write operation is a dedicated, named function (e.g.
set_identity,enable_interface) mapped to exactly one API path and action inguard.ALLOWLIST. There is no code path by which a caller can reach an API path outside that table. As of v0.7,actionisn't limited toupdate/add/remove:start_container/stop_containerusestart/stopto represent RouterOS's/container/start//container/stopACTION commands - but the dispatch mechanism (getattr(client, op.action)) and the fixed, individually-reviewedMikrotikClientmethod it can ever reach are unchanged; seeCHANGELOG.md's "How start/stop extends the guard". v0.10 adds a second such pair:clear_dns_cache/wake_on_lanuseflush/wolfor RouterOS's/ip/dns/cache/flush//tool/wolACTION commands - samegetattr(client, op.action)dispatch, same fixed reviewedMikrotikClient.flush/.wolmethods, the only difference fromstart/stopbeing that neither targets a specific row/.id(there is no "list" to pick one row from - both are standalone, one-shot commands, dispatched via the connection's callable form likeping, not thepath(*segments)(cmd, **{".id": id})formstart/stopuse).set_wifi_ssidandset_client_bandwidthare the two exceptions to "one tool, one allowlist entry": because RouterOS exposes wifi under different paths depending on generation (ROS7/interface/wifivs ROS6/interface/wireless) - and, on ROS7, the ssid itself lives in one of two different places depending on whether the interface references a namedconfiguration(see "ROS7 wifi:configuration-based SSID" below) -set_wifi_ssidis backed by three fixed, reviewed allowlist entries (set_wifi_ssid_ros7/set_wifi_ssid_ros7_configuration/set_wifi_ssid_ros6), and the guard function picks between them by reading the device: which path has a matching interface name, and then, for a ROS7 match, whether that interface's ssid is inline or lives on its referenced configuration profile. Likewise,set_client_bandwidtheither updates an existing Simple Queue or creates a new one, so it is backed byset_client_bandwidth_update/set_client_bandwidth_add, and the guard function picks between them by checking whether a queue already targets the giventarget. In both cases the choice is made entirely by the guard function reading the device - never by accepting a path or an add-vs-update decision from the caller.Explicit confirm with before/after preview. Every write tool takes a
confirm: boolparameter. Withconfirm=False(the default), the tool computes and returns what would change - abefore/afterstructure - without applying anything. Onlyconfirm=Trueapplies the change.
Since v1.11, a fourth control applies specifically to LOCKOUT-RISK
writes (today: set_wireless_channel/set_wireless_tx_power, and
set_wireless_tuning when given a numeric distance - a bad frequency/
power/ACK-timeout on a management-path radio link can cut the only route
back to the device): the dead-man. A confirm=True apply arms a local,
self-removing RouterOS scheduler before the write, which reverts it
automatically once its deadline is reached unless explicitly cancelled
(cancel_dead_man) - see "Dead-man / lockout-proof writes" below for the
full mechanism. This is this project's in-architecture answer to giving up
RouterOS's own Safe Mode (see "Explicitly NOT on the roadmap" in
ROADMAP.md) in exchange for staying API-only.
ROS7 wifi: configuration-based SSID
Confirmed against real ROS7 hardware (a mANTBox): in the standard production
layout, a /interface/wifi interface references a named configuration
(e.g. configuration=cfg1) and has no writable ssid field of its own -
writing one directly there is rejected by RouterOS ("unknown parameter
ssid"). The ssid instead lives on the referenced
/interface/wifi/configuration row.
set_wifi_ssid resolves this automatically: for a ROS7 match, it checks the
interface's own configuration field, looks up the matching
/interface/wifi/configuration row by name, and reads/writes the ssid
there (backed by the set_wifi_ssid_ros7_configuration allowlist entry).
The before/after preview always reflects the ssid's real location - it
is never synthesized on a field that doesn't actually exist on the device.
Only a wifi interface with no named configuration at all (rare/legacy)
keeps a genuinely inline ssid field, written directly on /interface/wifi
as before. If neither shape is recognized (a configuration name that
doesn't resolve, or an interface with neither an inline ssid nor a
configuration reference), the tool raises a clear error rather than
sending a write RouterOS would itself reject.
On top of the write guard:
Never creates the target. Write tools that operate on a named resource (
enable_interface/disable_interface/set_wifi_ssid/remove_simple_queue/remove_from_address_list/set_poe_out/start_container/stop_container/set_route_distance/enable_route/disable_route/remove_route/remove_netwatch/enable_firewall_rule/disable_firewall_rule/add_wireguard_peer/remove_wireguard_peer) look it up first - by name, by the v0.9 route tools' stabledst-address(+gateway/comment) identifier described in "Failover control" above, by the v0.11 firewall rule tools'comment(+chain) described in "Firewall rule toggle (by comment)" above, or, for the v0.13 WireGuard peer tools, bypublic-key/commentscoped to a giveninterface(described in "WireGuard management" above). If nothing matches, the tool raises a clear error instead of creating one - a typo can never silently provision something new.set_poe_outadditionally requires the matched interface to actually have apoe-outfield (i.e. be PoE-capable hardware) - a name that exists but isn't a PoE port raises the same clear error rather than doing nothing silently.add_wireguard_peeradditionally requires itsinterfaceto already exist as a WireGuard tunnel - it never creates one (useadd_wireguard_interfacefirst). The v0.9 route tools, the v0.11 firewall rule tools, and the v0.13remove_wireguard_peeradditionally never resolve by a RouterOS.idor a list index (both can shift as rows are added/removed elsewhere on the device); if an identifier still matches more than one row after narrowing,AmbiguousResourceErroris raised instead of guessing which one to touch.Never silently duplicates.
add_static_dhcp_leasechecks for an existing lease on the givenmac_addressfirst,add_to_address_listchecks for an existing entry with the samelist_name+addresspair first,add_netwatchchecks for an existing monitor on the givenhostfirst,add_wireguard_interfacechecks for an existing interface with the givennamefirst, andadd_wireguard_peerchecks for an existing peer with the samepublic_keyon the sameinterfacefirst - each raisingResourceAlreadyExistsErrorinstead of creating a second one - both forconfirm=falsepreviews andconfirm=trueapplies.Structured API, not shell commands. All device communication goes through
librouteros's structured API (path().select()/.add()/.update()/.remove(), and the callable form for one-off commands like ping). Nothing in this codebase builds a command by concatenating strings from user input, so command injection is ruled out by construction rather than by input filtering.Input validation on top, for its own sake.
ping'saddressis still validated against an IPv4/IPv6/hostname pattern before use, purely to reject garbage input early with a clear error - not as an injection defense (see previous point).No secrets in output.
Device.to_public_dict()is the only device representation ever returned to a tool caller, and it omits the password field entirely. Passwords are never logged. Since v0.8,wireguard_peersstrips aprivate-keyfield from every row viaformatting.strip_sensitive_fieldsbefore returning it - defensively, since RouterOS's own/interface/wireguard/peersreply doesn't carry one in the first place (see "VPN & routing diagnostics" above). Since v0.12,security_auditnever copies a raw row (or a credential-shaped field from one) into a finding - every finding's text comes from a fixed template referencing only non-secret fields, even though several of the menus it reads (/ip/service,/snmp/community, wireless/wifi security profiles,/user) can carry a password/passphrase/community-string fieldsee "Security audit" above. Since v0.13,
wireguard_interfacesand every WireGuard write tool (add_wireguard_interface/add_wireguard_peer/remove_wireguard_peer) apply the same redaction to a genuinely secret-bearing menu - a tunnel interface'sprivate-keyand a peer'spreshared-key- before a write's before/after preview is ever constructed, soguard.py's own audit journal (which logs exactly what a write function returns) can never carry one either; see "WireGuard management" above for the full two-layer redaction (guard.pyfirst,audit._SENSITIVE_KEYas a second, independent line of defense).
Structured errors. All errors raised inside the package derive from
MikrotikMCPError(seesrc/mcp_mikrotik/exceptions.py) and are caught at the tool boundary inserver.py. The exception is deliberately re-raised, not turned into an{"error": ...}result dict: MCP itself turns an exception propagating out of a tool into a properisErrortool result carrying just that exception's (already-safe) message, and letting the framework do that keeps every tool's declared return type honest (a successfullogscall always returnslist[dict], never sometimes a dict-shaped error instead). Unexpected exceptions are logged server-side and re-raised as a generic internal-error message, never as a raw traceback.
Production hardening: audit log, correlation IDs, retries, circuit breaker
The remaining layers around the write-guard mechanism above, aimed at
running mcp-mikrotik unattended against a real fleet. None of them can
weaken or bypass the read-only gate, the central allowlist, or the
confirm/preview flow - every write still goes through guard.py exactly as
described in "Security model", and the circuit breaker in particular never
skips guard.py's read-only gate/allowlist check: that check runs first,
entirely before MikrotikClient ever attempts a connection.
Audit journal. Every guarded write call (
guard.py, one of theALLOWLISToperations) emits exactly one structured JSON-lines event - whether it previewed (confirm=false), applied (confirm=true), or failed at any point, however early (even a write blocked by the read-only gate before the device is ever touched). Each event has atimestamp,correlation_id,device_name,tool,operation(theALLOWLISTkey),action(add/update/remove/start/stop/flush/wol/save- see v0.7'sstart_container/stop_container, v0.10'sclear_dns_cache/wake_on_lan, and v0.14'screate_backup),confirm,outcome(preview/applied/error), and asummaryof the before/after change plus that write'swarning(e.g.disable_route's default-route callout,remove_dhcp_lease's static-lease callout - seeWritePreview.warninginguard.py), or the error.summary.warningisnullfor every write that carries no special risk. Never includes a device password or any field that looks like a secret - seesrc/mcp_mikrotik/audit.py's_sanitize(). Destination isMIKROTIK_AUDIT_LOG(a file path, appended to) if set, otherwise a plainINFO-level line via the standard logger (stderr). Writing the journal is always best-effort: a bad path or a permissions error is logged as a warning and never blocks or fails the write it is describing. Read tools are never journaled - only guarded writes are.Correlation IDs. Every MCP tool call (read or write) gets a short, unique id (
uuid4().hex[:12]) for its duration - seesrc/mcp_mikrotik/correlation.py. It is bound once per call inserver.py's_safewrapper, appears in every audit journal entry a write call produces, and is prefixed onto the server-side log line if the call fails - so one id lets you grep everything one tool call did, end to end, without changing the shape of any error message returned to the caller.Read retry.
path/ping/traceroute(every read primitive insrc/mcp_mikrotik/client.py) automatically retry on a transient network error - a fresh connection attempt failing, or an in-flight command failing because of an underlyingOSError(a dropped socket, a timeout) - with a short backoff (0.5s, then 1s). Up toMIKROTIK_READ_RETRIESextra attempts (default 2). A command rejected by RouterOS itself (aLibRouterosError, not anOSError) is never retried - it would just be rejected again. Writes never retry, regardless of this setting:update/add/remove/start/stoparen't guaranteed idempotent, so retrying one could duplicate or reapply a change.Circuit breaker. Each device gets its own in-memory, thread-safe breaker (
client.CircuitBreaker, one instance per pooledMikrotikClientsee
ClientPool). AfterMIKROTIK_BREAKER_THRESHOLDconsecutive connection failures (default 3), the circuit opens: for the nextMIKROTIK_BREAKER_COOLDOWNseconds (default 30), any call to that device - read or write - fails immediately with a clearcircuit open for '<device>', retry after <t>serror, without attempting a connection at all. A single successful connection resets the failure count and closes the circuit. This is scoped purely to the connection step - it never decides whether a write is allowed (that's the read-only gate/allowlist inguard.py, always checked first) - it exists to stop a dead device (e.g. an antenna that fell over) from costing a full connect timeout on every single tool call.
TLS verification for api-ssl
RouterOS's api-ssl typically serves a self-signed certificate, so the
default tls_verify: true (which validates against the system trust store,
or an explicit tls_ca_cert if given) will fail out-of-the-box for most
fleets. Two ways to make an SSL device work:
Set
tls_ca_cert: /path/to/ca.pemon the device entry, if you provision RouterOS with a certificate you can pin, keeping full verification.Set
tls_verify: falseon that specific device to skip certificate/hostname validation entirely. This is a deliberate, explicit, per-device trade-off (it drops MITM protection on that connection) - it is never the default, and it is opt-in per device, not global. Seedevices.yaml.example.
Development & CI
pip install -e ".[dev]"
pytest -qThe test suite never talks to a real router: tests/fakes.py provides an
in-memory fake that implements the same minimal interface MikrotikClient
expects from a librouteros connection, and it is injected via a
client_factory parameter on build_server(). It currently has 1298 tests,
zero of which touch a real device or the network, at 100% line coverage.
CI (.github/workflows/ci.yml, GitHub Actions) runs on every push to main
and every pull request, as three separate jobs that must all pass before a
change is considered mergeable:
test- the fullpytestsuite against a matrix of Python 3.11, 3.12, and 3.13, with coverage measured (pytest --cov=mcp_mikrotik --cov-fail-under=95). The 95% floor is deliberately a little below the repo's actual coverage (100%, with two narrow# pragma: no coverexceptions - see CONTRIBUTING.md's "Test coverage") so a PR isn't blocked on a fraction-of-a-percent of genuinely hard-to-reach code, while still failing the build if coverage regresses meaningfully.lint-ruff check .(lint) andruff format --check .(formatting), configured inpyproject.toml's[tool.ruff].typecheck-mypy src/mcp_mikrotik, configured inpyproject.toml's[tool.mypy].librouterosships no type stubs, so it's covered instead byclient.py's ownRouterosConnection/RouterosPathProtocols, which the rest of the codebase is type-checked against.
Run all three locally before opening a PR - see CONTRIBUTING.md for the
exact commands, plus the full checklist a PR is reviewed against (the
security-model rules every write tool must follow, and the step-by-step
guide for adding a new tool, read or write). See ROADMAP.md for what's
next and why - candidate tools are filtered through the same security-model
invariants before landing there.
Roadmap & non-goals
Delivered through 1.0
Every tool round originally planned for this project has shipped - see
CHANGELOG.md for the full version-by-version detail:
Core read tools + guarded writes (v0.1-v0.4): device/interface/route reads,
set_identity,enable_interface/disable_interface,set_wifi_ssid,set_client_bandwidth,add_static_dhcp_lease,remove_simple_queue,add_to_address_list/remove_from_address_list.Production hardening (v0.5): audit journal, correlation IDs, read retry, circuit breaker - see "Security model" above.
Physical layer, LTE, containers, USB (v0.6-v0.7):
set_poe_out,lte_status/lte_interfaces,start_container/stop_container+containers/container_config,usb_devices.VPN/routing diagnostics + failover control (v0.8-v0.9):
wireguard_peers,ppp_active,ipsec_active_peers,bgp_sessions,ospf_neighbors,netwatch(read), plus the guarded failover write toolsset_route_distance/enable_route/disable_route/add_netwatch/remove_netwatch- see "Failover control" above. Netwatch up/down-script configuration itself remains deliberately out of scope (manual, on the device) - see "Netwatch scripts are never accepted" above for why.DNS/DHCP/Wake-on-LAN write tools (v0.10):
add_static_dns/remove_static_dns,clear_dns_cache,remove_dhcp_lease,wake_on_lan- see "DNS management", "DHCP lease removal", and "Wake-on-LAN" above. Only the two simplest static DNS record types ("A"/"CNAME") are exposed; other RouterOS record types (AAAA/MX/TXT/NS/...) remain a future decision, not silently widened here.Firewall rule toggle + connection tracking (v0.11):
enable_firewall_rule/disable_firewall_rule(adisabledTOGGLE only, on an existing, admin-authored rule resolved bycomment- never rule creation or any other field) andconnection_tracking(mandatory filter, hard-capped at 100 rows) - see "Firewall rule toggle (by comment)" and "Connection tracking (filtered)" above.Security audit + security-relevant log events (v0.12):
security_audit,security_events- see "Security audit" above. Both are read-only and heuristic:security_auditdoes not (and will not) auto-remediate anything it finds - every finding exists to inform a human/LLM decision, not to be acted on automatically. Widening its check list (e.g. NAT exposure, more RouterOS-version-specific CVE checks) is a future decision, not silently expanded here.WireGuard VPN management (v0.13):
wireguard_interfaces,add_wireguard_interface,add_wireguard_peer,remove_wireguard_peer- see "WireGuard management" above.Hotspot vouchers, live traffic monitoring, and backup (v0.14):
hotspot_active,torch,list_backups,add_hotspot_user,create_backup- see "Hotspot vouchers", "Live traffic monitoring (torch)", and "Backup" above.1.0.0: no new tools - a polish/consolidation round (ambiguity handling on
remove_netwatch, the audit journal carrying a write'swarning, and this README).
Non-goals
These are deliberately not exposed, not because they're technically
hard, but because the standard guard/confirm/preview mechanism isn't
sufficient protection for them on its own - see the comment above
ALLOWLIST in guard.py:
Device reboot (
/system/reboot): there's no meaningful before/after preview for a reboot, and a bad batch reboot across a fleet has no dry-run or rollback. Would need its own confirmation/cooldown policy first.Backup RESTORE (
/system/backup/load): same risk class as reboot - loading a backup overwrites the device's entire running configuration and reboots it, with no meaningful before/after preview and no rollback.create_backup/list_backupsonly ever create/list backup files; restoring one stays a manual, on-device (WinBox/CLI) operation until it has its own confirmation/cooldown policy.Firewall filter rule CREATION or general modification (any
ip/firewall/filterwrite other than thedisabledtoggle): a single wrong rule (e.g. one that blocks the API port itself) can lock out all remote management access to the device, with no way to recover it over the same connection. Would need staged/rollback support (e.g. RouterOS safe mode) before it belongs in the allowlist -enable_firewall_rule/disable_firewall_rulesidestep that risk entirely by only ever touching a rule an admin already wrote and reviewed themselves, never authoring one.A generic "run this RouterOS command/path" tool: this is not a gap to be filled later - it is the exact failure mode this project exists to avoid (see "Status" above). Every write will always be a dedicated, named, individually-reviewed function in
guard.ALLOWLIST, never an arbitrary path+action a caller supplies.
Post-1.0 ideas
Further write tools are added by extending
guard.ALLOWLISTwith one new named operation and function each - see the comment block at the top ofguard.py. Do not add a generic write tool.A second entrypoint that periodically polls devices and pushes metrics to Firebase is planned to reuse
MikrotikClient/get_clientfromclient.py. Not implemented yet - see theTODO(collector)note at the bottom ofclient.py.
License
GPL-3.0-or-later - see LICENSE.
This project imports librouteros (GPL-2.0-or-later) in-process, so the combined work is distributed under the GPL. Versions up to 1.11.0 were published as Apache-2.0; from the next release on, the whole project is GPL-3.0-or-later.
Available Tools
114 toolsadd_hotspot_userA
Create a hotspot voucher user (/ip/hotspot/user add) for a
visitor - name/password are the login credentials; profile
(an existing hotspot user profile), limit_uptime (a RouterOS
duration, e.g. "01:00:00"), and limit_bytes_total (a positive
integer byte quota) are all optional.
QR/VOUCHER: the result always also includes username, password
(the plaintext voucher credentials - THIS IS THE POINT: the visitor
needs them), and qr_payload - a plain STRING the caller renders as
a QR code itself; this package deliberately does NOT generate a QR
IMAGE (no extra imaging dependency). Format chosen for qr_payload:
"<username>:<password>" - a plain, self-describing credential
pair, NOT a login URL and NOT a WIFI: payload. Two alternatives
were considered and rejected: a login URL
(http://<hotspot>/login?username=..&password=..) would need a
reliably-known hotspot LAN address, which this package has no way to
determine for an arbitrary device/deployment - and RouterOS's actual
captive-portal login is normally a POST with additional
session-specific tokens (chap-id/chap-challenge), so a bare GET
URL with a plaintext password wouldn't even reliably work; a WIFI:
payload is for auto-joining a WPA network, which is a different
credential than a hotspot LOGIN (walled-garden HTTP auth) entirely.
username:password makes no claim about network topology or login
mechanics that could be wrong for a given deployment - a caller
integrating with a specific captive portal can build its own login
URL from these two fields plus its own known portal address.
PASSWORD IN THE JOURNAL: unlike every secret this package has
handled before (a device password, a WireGuard private-key), this
tool's password is DELIBERATELY present in its result - but still
never written to the audit journal. See guard.add_hotspot_user's
docstring for exactly how that asymmetry holds.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a preview (including qr_payload) without changing
anything; call again with confirm=True to actually create it.
Errors clearly (without creating anything) if name already exists
on the device - it never creates a duplicate or resets an existing
voucher's password.
Inputs: name (required), password (required), device_name (required), confirm, +3 more. Returns: object
add_ipv6_routeA
Add a static IPv6 route (/ipv6/route add): dst_address and
gateway are required, distance (failover priority - lower wins)
and comment are optional. Mirrors add_route on the IPv6 menu -
never refuses a duplicate dst_address, multiple routes sharing one
is the normal failover shape.
dst_address/gateway must be IPv6 (an IPv4 address/subnet in
either is rejected before the device is ever touched - /ipv6/route
has no IPv4 concept).
RISK: adding/overriding the default route (dst_address="::/0")
redirects all outbound IPv6 traffic through the new gateway. The
returned preview's warning field is non-null whenever this is the
case - always check it before calling again with confirm=true.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview (including the warning
field) without changing anything; call again with confirm=True to
actually apply it. Also errors clearly if the ipv6 package is
disabled on the device.
Inputs: gateway (required), device_name (required), dst_address (required), comment, +2 more. Returns: object
add_netwatchA
Create a Netwatch host monitor (/tool/netwatch add): host
(a plain IPv4/IPv6 address), optional interval (a RouterOS
duration, e.g. "10s"/"00:00:10") and optional comment.
SECURITY: this tool does NOT accept an up-script/down-script - a Netwatch script body can run arbitrary RouterOS commands (route or credential changes, ...), so it is deliberately outside what this guarded write tool will ever send to a device. Configure up/down scripts manually on the device once the monitor exists (WinBox/CLI)
see README's "Failover control" section. The read-only
netwatchtool already only ever surfaceshas-up-script/has-down-scriptas presence booleans, never a script body, for the same reason.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors clearly
(without creating anything) if a monitor for host already exists -
it never creates a duplicate.
Inputs: host (required), device_name (required), comment, confirm, +1 more. Returns: object
add_ppp_secretA
Create a PPP/PPPoE secret (/ppp/secret add) - name/password
are the dial-in login credentials for a PPPoE/PPTP/L2TP/OpenVPN/SSTP
service. service (default "any") restricts which PPP service the
secret may authenticate for - one of "pppoe"/"pptp"/"l2tp"/"ovpn"/
"sstp"/"any". profile (an existing /ppp/profile name, e.g. to
assign an address pool or rate limit), remote_address (a literal
IP handed to the client on connect), and comment are all optional.
PASSWORD IN THE RESULT: like add_hotspot_user's voucher password,
this tool's password is DELIBERATELY present in its result (the
caller supplied it and gets it echoed back as confirmation) - but
still never written to the audit journal. See
guard.add_ppp_secret's docstring for exactly how that asymmetry
holds.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a preview without changing anything; call again
with confirm=True to actually create it. Errors clearly (without
creating anything) if name already exists on the device - it
never creates a duplicate or resets an existing secret's password.
Inputs: name (required), password (required), device_name (required), comment, +4 more. Returns: object
address_listsA
List firewall address-list entries (/ip/firewall/address-list): list, address, timeout, dynamic, disabled. See who's currently in which named list (e.g. a "blocked-clients" list a firewall rule drops), before adding/removing entries with add_to_address_list / remove_from_address_list.
Inputs: device_name (required). Returns: result
add_routeA
Add a static route (/ip/route add): dst_address and
gateway are required, distance (failover priority - lower wins)
and comment are optional. Never refuses a duplicate dst_address
multiple routes sharing one is the normal failover shape.
RISK: adding/overriding the default route (dst_address="0.0.0.0/0"
or "::/0") redirects all outbound traffic through the new
gateway. The returned preview's warning field is non-null
whenever this is the case - always check it before calling again
with confirm=true.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview (including the warning
field) without changing anything; call again with confirm=True to
actually apply it.
Inputs: gateway (required), device_name (required), dst_address (required), comment, +2 more. Returns: object
add_static_dhcp_leaseA
Create a static DHCP lease (/ip/dhcp-server/lease), pinning
address to mac_address. Useful to give a client a stable,
predictable IP - e.g. before limiting it with set_client_bandwidth.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors clearly
(without creating anything) if a lease for mac_address already
exists on the device - it never creates a duplicate.
Inputs: address (required), device_name (required), mac_address (required), server, +2 more. Returns: object
add_static_dnsA
Create a static DNS entry (/ip/dns/static add) resolving name
to address.
record_type is "A" (default) or "CNAME": for "A", address
is a literal IPv4/IPv6 address; for "CNAME", address is itself
another hostname (the alias target), written to RouterOS's cname
field. Useful to block a malicious domain (point it at 0.0.0.0) or
set up an internal DNS override.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors clearly
(without creating anything) if a row already matches this exact
name+record_type pair - it never creates a duplicate.
Inputs: name (required), address (required), device_name (required), ttl, +3 more. Returns: object
add_to_address_listA
Add address (an IP or subnet) to a named firewall address-list
(/ip/firewall/address-list).
IMPORTANT: this only manages the list - it does NOT create or
modify any firewall rule. Adding an address here only blocks or
allows traffic if a /ip/firewall/filter (or NAT) rule on the
device already references list_name (e.g.
src-address-list=list_name, action=drop). See README's
"Blocking/allowing a client via address lists" section.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors clearly
(without creating anything) if this exact list_name+address pair
already exists on the device - it never creates a duplicate.
Inputs: address (required), list_name (required), device_name (required), comment, +2 more. Returns: object
add_to_ipv6_address_listA
Add address (an IPv6 address or subnet) to a named IPv6
firewall address-list (/ipv6/firewall/address-list). Mirrors
add_to_address_list on the IPv6 menu.
IMPORTANT: this only manages the list - it does NOT create or
modify any firewall rule. Adding an address here only blocks or
allows traffic if an /ipv6/firewall/filter rule on the device
already references list_name. See README's "Blocking/allowing a
client via address lists" section.
address must be IPv6 (an IPv4 address/subnet is rejected before
the device is ever touched).
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors clearly
(without creating anything) if this exact list_name+address
pair already exists on the device - it never creates a duplicate.
Also errors clearly if the ipv6 package is disabled on the
device.
Inputs: address (required), list_name (required), device_name (required), comment, +2 more. Returns: object
add_vlanA
Create a VLAN interface (/interface/vlan add): name (the new
RouterOS interface name, e.g. "vlan100"), vlan_id (1-4094, the
IEEE 802.1Q tag), interface (the parent interface it rides on top
of, e.g. "bridge1"/"ether2" - not verified to exist here; RouterOS
itself rejects an unknown one at write time). mtu/comment are
optional.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually create it. Errors clearly
(without creating anything) if name already exists on the device -
it never creates a duplicate.
Inputs: name (required), vlan_id (required), interface (required), device_name (required), +3 more. Returns: object
add_wireguard_interfaceA
Create a WireGuard tunnel interface (/interface/wireguard add).
RouterOS generates the interface's private-key internally - this
tool never accepts (or returns) one. The confirm=False preview's
after only describes what will be created (name, listen-port
if given) - it does not invent a public-key, since RouterOS hasn't
generated the key pair yet at preview time. The confirm=True
applied result re-reads the created interface and reports its real
public-key, with private-key always stripped. See README's
"WireGuard management" section.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to preview without changing anything; call again with
confirm=True to actually create it. Errors clearly if name already
exists - never creates a duplicate.
Inputs: name (required), device_name (required), confirm, listen_port. Returns: object
add_wireguard_peerA
Add a WireGuard peer (/interface/wireguard/peers add) to an
existing tunnel interface.
public_key is the REMOTE peer's own public key (base64, 44 chars).
allowed_address is a comma-separated list of CIDR ranges routed
through this peer (e.g. "10.0.0.2/32,10.0.0.3/32").
endpoint_address/endpoint_port (the peer's reachable
address/port, if any) and persistent_keepalive (a RouterOS
duration, e.g. "25s") are optional.
Does NOT accept a private-key or preshared-key parameter - the remote peer's own private key, and any preshared key, are entirely out of this tool's scope.
interface must already exist - create it first with
add_wireguard_interface; errors clearly if it doesn't. Refuses to
add a duplicate peer (same public_key already registered on the
same interface) - never creates a duplicate.
WRITE tool, guarded: blocked entirely unless the server is running with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the default) to preview without changing anything; call again with confirm=True to actually add it.
Inputs: interface (required), public_key (required), device_name (required), allowed_address (required), +5 more. Returns: object
arm_dead_manA
Arm a local, self-removing RouterOS scheduler on device_name
that reverts a change after minutes (1-60) unless cancelled first
(cancel_dead_man) - the anti-lockout primitive behind every
LOCKOUT-RISK write in this package (set_wireless_channel/
set_wireless_tx_power use it automatically by default). See
README's "Dead-man / lockout-proof writes" section for the full
design and the real-hardware incident that validated it.
NOT wireless-specific: revert_commands is any non-empty list (max
10 items) of RouterOS script statements that restore a known-good
prior state - a route, a bridge port, a firewall rule, anything -
run in order when the dead-man fires, after logging a warning
(visible in logs/security_events) and before the scheduler
removes itself. Build each command from state you already read from
the device, not free-form text.
The returned preview's after["name"] (also after more broadly)
is the exact scheduler that would be armed (or was armed, if
confirm=True) - pass that name to cancel_dead_man once the
change it guards is confirmed good.
WRITE tool, guarded: blocked entirely unless the server is running with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the default) to preview the exact scheduler that would be armed without touching the device; call again with confirm=True to actually arm it.
Inputs: device_name (required), revert_commands (required), confirm, minutes. Returns: object
arp_tableA
List the IPv4 ARP table (/ip/arp): address, mac-address, interface, dynamic, complete.
Use this to cross-reference an IP to a MAC (or vice versa) for a
statically-addressed device that never shows up in dhcp_leases
(it never requested a DHCP lease, so it has no lease entry - but it
does get an ARP entry once it has exchanged traffic with the
device).
Inputs: device_name (required). Returns: result
bgp_sessionsA
List BGP session status: remote-address/remote-as, state (established/idle/...), uptime, prefix-count.
RouterOS exposes this under two different paths depending on generation, the same split wireless_registrations already handles for wifi: ROS7's routing package (/routing/bgp/session) or ROS6's (/routing/bgp/peer). This tries ROS7 first, falls back to ROS6, and returns an empty list - rather than raising - for a device that doesn't run BGP at all.
Inputs: device_name (required). Returns: result
bridge_hostsA
List /interface/bridge/host entries: mac-address, on-interface (the physical bridge port), bridge, dynamic, local.
Use this to find which physical port of a bridge a given MAC is
currently learned on - e.g. to identify which PoE-capable ethernet
port a locked-up device is plugged into, before using poe_status/
set_poe_out on it (see "Physical layer & PoE control" in the
README).
Inputs: device_name (required). Returns: result
bridge_portsA
List bridge port membership (/interface/bridge/port): bridge,
interface, pvid, disabled, edge, horizon, learn,
comment. Only disabled is normalized to bool | None
(formatting.coerce_ros_bool) - edge/learn are RouterOS enums
(e.g. auto/yes/no/yes-discover/no-discover), not strict
booleans, so they are left as RouterOS's own raw value rather than
coerced.
Use this (with bridge_vlans below) to see which physical port of
a managed switch's bridge a VLAN actually applies to - bridge_hosts
only shows the MAC table, not port/VLAN configuration.
Inputs: device_name (required). Returns: result
bridge_vlansA
List bridge VLAN filtering table entries (/interface/bridge/vlan):
bridge, vlan-ids, tagged, untagged, comment, plus
current-tagged/current-untagged when the device's reply carries
them (RouterOS's own computed effective port lists - not present on
every ROS6/ROS7 version, so only added when actually present, never
invented).
This is the honest completion of the VLAN story for a MANAGED
SWITCH (CRS/hEX-style hardware): the v1.2 list_vlans/add_vlan/
remove_vlan tools operate on standalone /interface/vlan
interfaces (router-on-a-stick style routing), which is a DIFFERENT
RouterOS mechanism from bridge VLAN filtering - a switch that
segments traffic by VLAN across bridge ports needs THIS table, not
/interface/vlan. See bridge_ports above for per-port
pvid/edge/learn config.
Inputs: device_name (required). Returns: result
cancel_dead_manA
Cancel a dead-man scheduler armed by arm_dead_man (or
automatically by set_wireless_channel/set_wireless_tx_power),
once the change it guards is confirmed good - removes it from
/system/scheduler before it can fire and revert.
name MUST be the exact "deadman-" handle arm_dead_man
returned (after["name"], or a wireless write's dead_man["name"])
by construction this can never target an unrelated scheduler entry (e.g. an admin's own "backup-daily" task).
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to preview what would be cancelled; call again with
confirm=True to actually cancel it. Errors clearly if name doesn't
match an armed scheduler - which can mean it already fired and
self-removed (it wasn't cancelled in time), or was already
cancelled.
Inputs: name (required), device_name (required), confirm. Returns: object
certificatesA
List certificates (/certificate): name, common-name,
subject/issuer fields (if present), invalid-before/invalid-after
(raw RouterOS date strings, kept as-is), key-size/key-type,
fingerprint, and RouterOS's own flags (expired, trusted) - all
returned exactly as the device sends them (booleans may come back as
Python bool or be omitted entirely; see formatting.coerce_ros_bool
for a caller that needs to branch on expired/trusted rather than
just display them).
Adds a computed daysUntilExpiry (int, negative once past due) from
invalid-after whenever it can be parsed - see
formatting.parse_ros_datetime's docstring for the two RouterOS date
shapes handled ("2027-01-15 12:00:00" and
"jan/15/2027 12:00:00"). RouterOS's own date rendering varies by
version/locale; parsing is DEFENSIVE and never raises - a row whose
invalid-after doesn't match either known shape simply has no
daysUntilExpiry key added, with the raw invalid-after string left
untouched so a caller can still see it.
SECURITY: /certificate's own API reply never carries a private key
(RouterOS only returns certificate metadata over the API) - a
private-key field is nonetheless stripped defensively before
returning, in case a future RouterOS version or firmware quirk ever
adds one (same strip_sensitive_fields mechanism ppp_secrets/
wireguard_interfaces use). See
test_certificates_strips_private_key_defensively.
See also security_audit's certificate-expiry check (v1.6), which
flags an expired or soon-to-expire (<=30 days) certificate as a
finding using this same expiry logic.
Returns an empty list (never an error) for a device with no certificates configured.
Inputs: device_name (required). Returns: result
clear_dns_cacheA
Flush the device's DNS resolver cache (/ip/dns/cache/flush) -
no arguments, clears every cached DNS answer at once.
Benign (only cached answers are cleared - repopulated on the next resolution - never device configuration), but still guarded/ confirm-gated like every other write tool here.
WRITE tool, guarded: blocked entirely unless the server is running with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the default) to preview the current cached-entry count without changing anything; call again with confirm=True to actually flush it.
Inputs: device_name (required), confirm. Returns: object
connection_trackingA
List active connections from RouterOS's connection tracking table
(/ip/firewall/connection) - FILTERED. At least ONE of src_address,
dst_address, dst_port, protocol is REQUIRED.
WHY a filter is mandatory (unlike every other read tool in this package): on a production router, the full connection-tracking table can be large enough to blow past an LLM caller's context/token budget on its own. Calling this with no filter at all raises a ValidationError instead of returning the whole table.
Filtering happens in Python after reading the table - the same
reasoning logs' topics filter already documents (RouterOS's
structured API doesn't expose a query-by-field read here either).
src_address/dst_address match a row's IP, ignoring the port
RouterOS packs into the same field (e.g. "192.0.2.1:80" ->
address "192.0.2.1"); dst_port matches the destination's port
component. protocol is a RouterOS protocol name (e.g.
"tcp"/"udp"/"icmp", case-insensitive) or a numeric IP protocol
number (0-255).
Regardless of how many rows match, the result is capped at
MAX_CONNTRACK_LIMIT (100) entries - truncated is true whenever
more rows matched than were returned, and total_matched always
reports the real (pre-truncation) match count, so a caller always
knows whether it's seeing everything that matched.
Each returned entry: protocol, src-address/src-port,
dst-address/dst-port (address and port split apart - see
formatting.split_address_port), tcp-state (populated for TCP
connections), timeout, and the assured/confirmed/seen-reply
flags - RouterOS's own closest equivalent to a generic "connection
state" for this table.
Inputs: device_name (required), dst_port, protocol, dst_address, +1 more. Returns: object
container_configB
Container subsystem configuration (/container/config):
registry-url, tmpdir, ram-high, etc - a single-row menu. Returns an
empty dict (never an error) for a device with no container package.
Inputs: device_name (required). Returns: object
containersB
List containers (/container): name/tag, status, ram-usage,
root-dir, interface, os, etc. Returns an empty list (never an error)
for a device with no container package/hardware support at all.
Inputs: device_name (required). Returns: result
create_backupA
Create a RouterOS system backup file (/system/backup/save name=<name>) - captures the device's full configuration into one
binary .backup file on its own storage. Use list_backups
afterward to confirm it landed and see its real size/creation-time.
password, if given, is RouterOS's own backup-FILE encryption
option (unrelated to any device/API credential) - forwarded to the
device, but NEVER included in the returned preview, and never
journaled. See guard.create_backup's docstring for how that
redaction is enforced.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a preview of the file that would be created without
changing anything; call again with confirm=True to actually create
it. Errors clearly (without creating anything) if a .backup file
matching name already exists on the device - it never silently
overwrites one.
Inputs: name (required), device_name (required), confirm, password. Returns: object
dhcp_leasesC
List DHCP server leases (address, mac, host-name, status, server, comment).
Inputs: device_name (required). Returns: result
dhcp_networksA
List DHCP server networks (/ip/dhcp-server/network): address,
gateway, dns-server, netmask, domain, comment - the
per-subnet options a DHCP server (see dhcp_servers) hands out to
clients on lease. No boolean fields here to normalize.
Inputs: device_name (required). Returns: result
dhcp_serversA
List DHCP server CONFIG (/ip/dhcp-server) - as opposed to
dhcp_leases, which lists the leases a server has handed out. Each
entry keeps every field RouterOS returns (name, interface,
address-pool, lease-time, authoritative, comment, ...), with
disabled normalized to bool | None (formatting.coerce_ros_bool
never a
== "true"string-equality trap, see that helper's docstring).authoritativeis left as RouterOS's own raw value (it can be"yes"/"no"/"after-2sec-delay"/... - not a strict boolean - so it is not coerced).
Inputs: device_name (required). Returns: result
disable_firewall_ruleA
Disable an EXISTING firewall filter rule (/ip/firewall/filter set disabled=yes), resolved by its comment - optionally narrowed by
chain if more than one rule shares that comment.
Same "never creates a rule" guarantee and comment-based resolution
as enable_firewall_rule - see its docstring and README's "Firewall
rule toggle (by comment)" section.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors clearly if
no rule matches comment (narrowed by chain), or if more than one
still does (AmbiguousResourceError).
Inputs: comment (required), device_name (required), chain, confirm. Returns: object
disable_interfaceA
Disable a network interface by name (sets disabled=yes).
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors clearly if
interface_name does not exist on the device - it is never created.
Inputs: device_name (required), interface_name (required), confirm. Returns: object
disable_ipv6_firewall_ruleA
Disable an EXISTING IPv6 firewall filter rule (/ipv6/firewall/ filter set disabled=yes), resolved by its comment - optionally
narrowed by chain if more than one rule shares that comment.
Mirrors disable_firewall_rule on the IPv6 menu.
Same "never creates a rule" guarantee and comment-based resolution
as enable_ipv6_firewall_rule.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors clearly if
no rule matches comment (narrowed by chain), or if more than one
still does (AmbiguousResourceError). Also errors clearly if the
ipv6 package is disabled on the device.
Inputs: comment (required), device_name (required), chain, confirm. Returns: object
disable_mangle_ruleA
Disable an EXISTING firewall mangle rule (/ip/firewall/mangle set disabled=yes), resolved by its comment - optionally narrowed by
chain.
Same "never creates a rule" guarantee and comment-based resolution
as enable_mangle_rule.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors clearly if
no rule matches comment (narrowed by chain), or if more than one
still does (AmbiguousResourceError).
Inputs: comment (required), device_name (required), chain, confirm. Returns: object
disable_nat_ruleA
Disable an EXISTING firewall NAT rule (/ip/firewall/nat set disabled=yes), resolved by its comment - optionally narrowed by
chain (srcnat/dstnat).
Same "never creates a rule" guarantee and comment-based resolution
as enable_nat_rule.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors clearly if
no rule matches comment (narrowed by chain), or if more than one
still does (AmbiguousResourceError).
Inputs: comment (required), device_name (required), chain, confirm. Returns: object
disable_routeA
Disable a route (/ip/route set disabled=yes), resolved by
dst_address - narrowed by gateway/comment when more than one
route shares that dst_address. Errors clearly if nothing matches,
or if the match is still ambiguous after narrowing.
RISK: disabling the default route (dst_address="0.0.0.0/0" or
"::/0") cuts all outbound traffic that relies on this gateway. The
returned preview's warning field is non-null whenever this is the
case - always check it before calling again with confirm=true.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview (including the warning
field) without changing anything; call again with confirm=True to
actually apply it.
Inputs: device_name (required), dst_address (required), comment, confirm, +1 more. Returns: object
dns_cacheB
List cached DNS records on the device (name, type, data, ttl).
Inputs: device_name (required). Returns: result
enable_firewall_ruleA
Enable an EXISTING firewall filter rule (/ip/firewall/filter set disabled=no), resolved by its comment - optionally narrowed by
chain if more than one rule shares that comment.
SAFE BY DESIGN: this NEVER creates a rule. Intended workflow: an
admin creates a rule ahead of time on the device with a descriptive
comment (e.g. comment="Bloqueio_Ataque_X"), reviews it once, and
leaves it disabled; an LLM caller later enables it via this tool
when it detects the condition the rule exists to guard against. If
it goes wrong, the admin knows exactly which rule was toggled - the
same one they already wrote and reviewed. See README's "Firewall
rule toggle (by comment)" section.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview - the FULL matched rule, not
just its disabled field, so you can confirm WHICH rule this is -
without changing anything; call again with confirm=True to actually
apply it. Errors clearly if no rule matches comment (narrowed by
chain), or if more than one still does (AmbiguousResourceError)
never guesses which one to toggle.
Inputs: comment (required), device_name (required), chain, confirm. Returns: object
enable_interfaceA
Enable a network interface by name (sets disabled=no).
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors clearly if
interface_name does not exist on the device - it is never created.
Inputs: device_name (required), interface_name (required), confirm. Returns: object
enable_ipv6_firewall_ruleA
Enable an EXISTING IPv6 firewall filter rule (/ipv6/firewall/filter set disabled=no), resolved by its comment - optionally narrowed by
chain if more than one rule shares that comment. Mirrors
enable_firewall_rule on the IPv6 menu - see its docstring and
README's "Firewall rule toggle (by comment)" section for the full
admin-creates/LLM-enables workflow.
SAFE BY DESIGN: this NEVER creates a rule.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview - the FULL matched rule, not
just its disabled field - without changing anything; call again
with confirm=True to actually apply it. Errors clearly if no rule
matches comment (narrowed by chain), or if more than one still
does (AmbiguousResourceError) - never guesses which one to
toggle. Also errors clearly (does not return an empty result) if
the ipv6 package is disabled on the device - see "IPv6 write
parity (v1.10)" in the README.
Inputs: comment (required), device_name (required), chain, confirm. Returns: object
enable_mangle_ruleA
Enable an EXISTING firewall mangle rule (/ip/firewall/mangle set disabled=no), resolved by its comment - optionally narrowed by
chain (e.g. prerouting/postrouting/forward/input/output)
if more than one rule shares that comment.
Same "never creates a rule" guarantee and comment-based resolution
as enable_firewall_rule - extended to /ip/firewall/mangle.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview - the FULL matched rule, not
just its disabled field - without changing anything; call again
with confirm=True to actually apply it. Errors clearly if no rule
matches comment (narrowed by chain), or if more than one still
does (AmbiguousResourceError) - never guesses which one to toggle.
Inputs: comment (required), device_name (required), chain, confirm. Returns: object
enable_nat_ruleA
Enable an EXISTING firewall NAT rule (/ip/firewall/nat set disabled=no), resolved by its comment - optionally narrowed by
chain (srcnat/dstnat) if more than one rule shares that
comment.
Same "never creates a rule" guarantee and comment-based resolution
as enable_firewall_rule (see its docstring and README's "Firewall
rule toggle (by comment)" section) - extended to /ip/firewall/nat.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview - the FULL matched rule, not
just its disabled field - without changing anything; call again
with confirm=True to actually apply it. Errors clearly if no rule
matches comment (narrowed by chain), or if more than one still
does (AmbiguousResourceError) - never guesses which one to toggle.
Inputs: comment (required), device_name (required), chain, confirm. Returns: object
enable_routeA
Enable a route (/ip/route set disabled=no), resolved by
dst_address - narrowed by gateway/comment when more than one
route shares that dst_address (e.g. two default routes to
different gateways, the standard failover shape). Errors clearly if
nothing matches, or if the match is still ambiguous after
narrowing.
WRITE tool, guarded: blocked entirely unless the server is running with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the default) to get a before/after preview without changing anything; call again with confirm=True to actually apply it.
Inputs: device_name (required), dst_address (required), comment, confirm, +1 more. Returns: object
firewall_filterB
List IPv4 firewall filter rules (chain, action, etc). Read-only - does not add/modify/remove rules.
Inputs: device_name (required). Returns: result
firewall_mangleA
List IPv4 firewall mangle rules (/ip/firewall/mangle): chain
(e.g. prerouting/postrouting/forward/input/output, or a custom
jump-target chain), action (e.g. mark-connection/mark-packet/
mark-routing), comment, disabled, plus whatever other fields
RouterOS returns for a given rule (protocol, src/dst-address, etc -
these vary per rule/action, same as firewall_filter/firewall_nat
not every rule has every field, and this returns each row as-is rather than assuming a fixed shape). Read-only - does not add/ modify/remove rules. See "NAT & mangle rule toggle (by comment)" below for the guarded enable_mangle_rule/disable_mangle_rule pair.
Inputs: device_name (required). Returns: result
firewall_natA
List IPv4 firewall NAT rules (/ip/firewall/nat): chain, action, to-addresses, etc. Read-only - does not add/modify/remove rules.
Inputs: device_name (required). Returns: result
get_wireless_link_qualityA
PtP/PtMP link-quality diagnosis: the same registration-table data
wireless_registrations reads, normalized per peer into interface,
mac_address, signal_strength, signal_to_noise, tx_ccq,
rx_ccq, tx_rate, rx_rate, distance, uptime (see
formatting.normalize_wireless_registration).
This is the read to run before AND after set_wireless_channel/
set_wireless_tx_power/set_wireless_tuning to judge whether an RF
change actually helped - CCQ (Client Connection Quality) and
signal-to-noise are the two numbers that matter most for a PtP link;
tx_rate/rx_rate dropping right after a channel/power change is
expected for a few seconds while rates re-adapt, not a failure (see
set_wireless_tx_power's docstring).
Optional interface filters to peers associated on that one local
interface (validated for shape like every other interface_name
parameter in this package).
Field availability genuinely differs by generation: ROS7's newer
/interface/wifi/registration-table does not publish
signal-to-noise/tx-ccq/rx-ccq/distance at all (per MikroTik's
own docs) - those fields come back None rather than fabricated.
/interface/wireless (confirmed against real hardware today - see
docs/api-notes-wireless-rf.md) publishes all of them. Empty list
(not an error) for a device with no wireless radio, same convention
as wireless_registrations.
Inputs: device_name (required), interface. Returns: result
hotspot_activeA
List clients currently logged into the RouterOS hotspot
(/ip/hotspot/active): user, address, mac-address, uptime,
bytes-in/bytes-out - who is on the hotspot right now, and how
much they've each moved this session.
Returns an empty list (never an error) for a device with no hotspot
server configured, or simply no one logged in right now - same
convention as ppp_active/ipsec_active_peers for an optional
feature with no active sessions.
Inputs: device_name (required). Returns: result
interface_monitorA
Link/optical status of one ethernet interface
(/interface/ethernet/monitor once=yes) - status (link-ok/
no-link), rate, full-duplex (coerced to bool | None - see
formatting.coerce_ros_bool), auto-negotiation (RouterOS's raw
value - a "done"/"incomplete"-style state, not a strict boolean, so
left as-is), plus SFP/DDM optics fields WHEN the port has an SFP
cage and a module is present: sfp-temperature,
sfp-supply-voltage, sfp-tx-power, sfp-rx-power,
sfp-tx-bias-current, sfp-vendor-name, sfp-vendor-part-number,
sfp-wavelength, sfp-module-present (also coerced to
bool | None).
A plain copper port (the vast majority) has NONE of the sfp-*
fields in RouterOS's reply at all - each is only added to the
result when the device's reply actually carries it (.get/in
checks throughout, nothing invented). The reference hardware this
project was verified against (a mANTBox) has no SFP cage, so the
command path itself is confirmed but the DDM field VALUES are not
yet verified against real SFP optics - see ROADMAP.md.
interface is validated for shape (validate_interface_name)
before it is ever sent to the device - existence isn't checked
separately, so a typo'd/unknown interface name simply produces
whatever error RouterOS itself returns. Returns an empty dict if
the device answers with nothing (same "once" convention as
interface_traffic/poe_status/lte_status).
Inputs: interface (required), device_name (required). Returns: object
interfacesC
List network interfaces on a device.
Inputs: device_name (required), include_disabled. Returns: result
interface_trafficA
Current rx/tx traffic rate of one interface (/interface/monitor-traffic interface= once=yes).
interface is validated for shape (validate_interface_name) before
it is ever sent to the device - existence isn't checked separately
here (unlike the guarded write tools), so a typo'd/unknown interface
name simply produces whatever error RouterOS itself returns.
Returns a single reply dict - typically rx-bits-per-second/
tx-bits-per-second and rx-packets-per-second/tx-packets-per-second -
or an empty dict if the device returned nothing. once=yes makes
this a single instantaneous reading, not a continuous stream, so the
call always returns promptly.
Inputs: interface (required), device_name (required). Returns: object
ip_addressesC
List IPv4 addresses configured on a device.
Inputs: device_name (required). Returns: result
ip_poolsB
List IP pools (/ip/pool): name, ranges. Read-only.
Inputs: device_name (required). Returns: result
ip_routesB
List the IPv4 routing table of a device.
limit, if given, caps the number of rows returned (capped at 500);
omit it to get the full table, mirroring logs' limit parameter.
Inputs: device_name (required), limit. Returns: result
ipsec_active_peersA
List active IPsec peers (/ip/ipsec/active-peers): remote-address, state, uptime, rx/tx byte counters, side (initiator/responder).
Returns an empty list (never an error) for a device that doesn't use IPsec at all - a completely normal state.
Inputs: device_name (required). Returns: result
ipv6_addressesA
List IPv6 addresses configured on a device (/ipv6/address):
address, interface, advertise, disabled, dynamic, plus whatever
other fields RouterOS's own reply carries (e.g. its global/
link-local classification). Mirrors ip_addresses for IPv6.
Returns an empty list (never an error) if the ipv6 package is
disabled on the device - see "IPv6 read parity (v1.9)" in the
README.
Inputs: device_name (required). Returns: result
ipv6_firewall_address_listsA
List IPv6 firewall address-list entries
(/ipv6/firewall/address-list): list, address, dynamic, disabled.
Mirrors address_lists for IPv6.
Returns an empty list (never an error) if the ipv6 package is
disabled on the device - see "IPv6 read parity (v1.9)" in the
README.
Inputs: device_name (required). Returns: result
ipv6_firewall_filterA
List IPv6 firewall filter rules (/ipv6/firewall/filter): chain,
action, etc. Mirrors firewall_filter for IPv6. Read-only - does
not add/modify/remove rules.
Returns an empty list (never an error) if the ipv6 package is
disabled on the device - see "IPv6 read parity (v1.9)" in the
README.
Inputs: device_name (required). Returns: result
ipv6_neighborsA
List the IPv6 neighbor discovery table (/ipv6/neighbor):
address, mac-address, interface, status, dynamic. IPv6's neighbor-
discovery equivalent of arp_table's IPv4 ARP table.
Returns an empty list (never an error) if the ipv6 package is
disabled on the device - see "IPv6 read parity (v1.9)" in the
README.
Inputs: device_name (required). Returns: result
ipv6_routesA
List the IPv6 routing table of a device (/ipv6/route):
dst-address, gateway, distance, active, dynamic, disabled. Mirrors
ip_routes for IPv6, including its optional limit (capped at
500, same MAX_ROUTE_LIMIT); omit it to get the full table.
Returns an empty list (never an error) if the ipv6 package is
disabled on the device - see "IPv6 read parity (v1.9)" in the
README.
Inputs: device_name (required), limit. Returns: result
list_backupsA
List backup files stored on the device (/file, filtered to
names ending in .backup): name, size, creation-time.
Reads the same /file menu create_backup's duplicate-name check
uses - call this after create_backup to confirm a new backup
landed and see its real size. Returns an empty list (never an
error) if the device has no backup files at all.
Inputs: device_name (required). Returns: result
list_devicesA
List configured MikroTik devices. Read-only; passwords are never included.
Inputs: no named parameters. Returns: result
list_vlansA
List VLAN interfaces (/interface/vlan): name, vlan-id,
interface (the parent interface it rides on top of), mtu,
running, disabled, comment (if set).
Excludes disabled VLAN interfaces by default, like interfaces;
pass include_disabled=True to see them too.
Inputs: device_name (required), include_disabled. Returns: result
list_write_operationsA
List every guarded write operation and the RouterOS path/action it maps to.
Read-only: this only surfaces guard.ALLOWLIST's metadata (D3) - it does not perform or preview a write, and is not gated by MIKROTIK_ALLOW_WRITE.
Inputs: no named parameters. Returns: result
logsA
Read recent RouterOS log entries (most recent last).
limit must be positive and is capped at 500. topics, if given, is
matched as a plain substring against each entry's topics field - no
regex, no unbounded scans - and is applied BEFORE the limit cut:
the full log is filtered by topics first, then the last limit
matching entries are returned (not the last limit raw entries,
then filtered - that would silently drop matches).
R1: this reads the whole /log table via librouteros' path().select()
and slices in Python rather than asking RouterOS for only the last
limit rows. librouteros' structured API doesn't expose a clean
"give me only the tail" query for /log (RouterOS's own count-only
print flags aren't reachable through path().select() the way a
.limit()/offset would be), so a "request fewer rows" optimization
here would mean building a fragile ad-hoc workaround for a table
that is small in practice (a few hundred to low thousands of rows on
RouterOS's own ring buffer). Left as-is; revisit if a real device
turns out to have a much larger log buffer than expected.
Inputs: device_name (required), limit, topics. Returns: result
lte_interfacesB
List LTE/5G modem interfaces (/interface/lte): name, running,
disabled, apn-profiles, etc. Returns an empty list (never an error)
for a device with no LTE hardware/package at all.
Inputs: device_name (required). Returns: result
lte_statusA
Signal/status of one LTE/5G modem interface
(/interface/lte/monitor <interface> once=yes).
Returns a single reply dict - typically operator (current-operator),
technology (access-technology: 3G/LTE/5G), signal
(rsrp/rsrq/sinr/rssi), band, registration-status,
cell-id - or an empty dict if the device has no LTE hardware/package
at all, or interface doesn't match one (same "empty, not an error"
convention as poe_status/system_health for optional hardware).
interface is validated for shape (validate_interface_name) before
it is ever sent to the device.
Inputs: interface (required), device_name (required). Returns: object
move_firewall_ruleA
Reorder an EXISTING firewall filter rule (/ip/firewall/filter move), resolved by its comment - optionally narrowed by chain
if more than one rule shares that comment, same resolution
enable_firewall_rule/disable_firewall_rule use.
SAFE BY DESIGN: this NEVER creates or otherwise edits a rule's fields - only its position in the chain's evaluation order changes.
Exactly one of before_comment (move the rule to appear immediately
before the EXISTING rule with this comment) or position (move the
rule to this 0-based index among the OTHER rules - a value at or
beyond the end of that list moves it to the very end) must be given.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview (the rule's comment/chain
plus its current vs. new position) without changing anything; call
again with confirm=True to actually apply it. Errors clearly if no
rule matches comment (narrowed by chain), if before_comment is
given but matches no rule, or if either still matches more than one
rule (AmbiguousResourceError) - never guesses.
Inputs: comment (required), device_name (required), chain, confirm, +2 more. Returns: object
neighborsB
List neighbors discovered via RouterOS neighbor discovery (CDP/MNDP/LLDP).
Inputs: device_name (required). Returns: result
netwatchA
List Netwatch host monitors (/tool/netwatch): host, status
(up/down), interval, since, comment, disabled, plus
has-up-script/has-down-script booleans.
Netwatch is the usual way a RouterOS device itself watches a gateway or peer's reachability (e.g. to drive a failover script on down/up) - this is read-only groundwork for future failover tooling; see README's "VPN & routing diagnostics".
The up-script/down-script fields are surfaced only as presence
booleans (has-up-script/has-down-script), never as the raw
script body, which can contain arbitrary RouterOS commands (e.g.
route/credential changes) that don't belong in a read tool's
output.
Returns an empty list (never an error) for a device with no Netwatch entries configured.
Inputs: device_name (required). Returns: result
ntp_clientB
NTP client configuration/status (/system/ntp/client) - a
single-row menu, same shape as container_config. Every field
RouterOS's own reply carries is returned as-is (.get-based,
nothing invented) - only enabled is normalized to bool | None
(formatting.coerce_ros_bool).
ROS7 fields: enabled, mode, servers (a comma-joined string of
every configured NTP server - left exactly as RouterOS sends it, NOT
split into a list; same "no invented shape" convention
bridge_vlans' tagged/untagged fields already follow),
freq-drift, status, synced-server, synced-stratum.
ROS6 fields: enabled, primary-ntp, secondary-ntp,
server-dns-names, mode. See set_ntp_servers for how a write
detects and targets whichever of these two shapes a device actually
speaks.
Returns an empty dict (never an error) for a device with no NTP client menu at all.
Inputs: device_name (required). Returns: object
ospf_neighborsA
List OSPF neighbor adjacencies (/routing/ospf/neighbor): address, state (Full/Down/...), router-id, adjacency.
Returns an empty list (never an error) for a device that doesn't run OSPF at all.
Inputs: device_name (required). Returns: result
pingB
Ping an address from a device. address must be a valid IPv4/IPv6 address or hostname.
Inputs: address (required), device_name (required), count. Returns: result
poe_statusA
PoE status/consumption per port, for every PoE-capable ethernet port on the device.
Reads /interface/ethernet and keeps only rows that have a poe-out
field (i.e. are PoE-capable on this hardware - e.g. the CRS318-16P's
high/low PoE ports), then reads
/interface/ethernet/poe/monitor once=yes for each one to get its
live voltage/current/power/poe-out-status. Each entry looks like
{"interface", "poe-out" (configured mode), "poe-out-status",
"voltage", "current", "power"} - the monitor fields are omitted for
a port whose live monitor call fails (kept resilient rather than
failing the whole tool for one bad port).
voltage/current/power are normalized to int | float | None
via formatting.coerce_ros_number, never passed through raw: real
hardware (CRS318-16P-2S+, ROS6.49.20) mixes int and string-decimal
(e.g. "4.7") types for these same three fields within a single
monitor reply, and across different ports of the same device - see
that helper's docstring. Without this, a caller comparing
voltage > 0 (see README's PoE power-cycle walkthrough) could get a
string on one port and a number on another.
Returns an empty list (never an error) for a device with no PoE
hardware at all - that's a completely normal state, same as
wireless_registrations for a wired-only device.
Inputs: device_name (required). Returns: result
ppp_activeA
List active PPP sessions (/ppp/active): name, service (l2tp/pptp/sstp/ovpn/pppoe), caller-id, address, uptime - VPN server sessions currently connected to this device.
Returns an empty list (never an error) for a device with no PPP server configured, or simply no sessions active right now.
Inputs: device_name (required). Returns: result
ppp_secretsA
List CONFIGURED PPP/PPPoE secrets (/ppp/secret): name, service (pppoe/pptp/l2tp/ovpn/sstp/any), profile, remote-address, local-address (if set), disabled, comment, last-logged-out (if set)
the dial-in credentials themselves, as opposed to
ppp_active's currently-CONNECTED sessions. See "PPP/PPPoE secrets" below.
SECURITY: RouterOS's own /ppp/secret reply carries each secret's
plaintext password - this is ALWAYS stripped before returning
(formatting.strip_sensitive_fields), the same mechanism
wireguard_interfaces uses for a tunnel interface's private-key. A
secret's password never leaves this process via this tool. See
test_ppp_secrets_never_exposes_password.
Returns an empty list (never an error) for a device with no PPP
package configured at all - same convention as ppp_active.
Inputs: device_name (required). Returns: result
radiusA
List RADIUS server configuration (/radius): service,
address, timeout, accounting-port, authentication-port, and
whatever other fields RouterOS returns for a given entry.
SECURITY: RouterOS's own /radius reply carries the plaintext
shared secret - this is ALWAYS stripped before returning
(formatting.strip_sensitive_fields), the same mechanism
ppp_secrets uses for /ppp/secret's password and
wireguard_interfaces uses for a tunnel interface's private-key.
A RADIUS shared secret never leaves this process via this tool. See
test_radius_never_exposes_secret.
Returns an empty list (never an error) for a device with no RADIUS servers configured.
Inputs: device_name (required). Returns: result
remove_dhcp_leaseA
Remove a DHCP lease (/ip/dhcp-server/lease remove) by address
or mac_address - typically to force a client to renew its IP. At
least one of address/mac_address must be given and must match an
existing lease (mac_address is tried first if both are given).
Removes EITHER a dynamic or a static lease. If the resolved lease
is STATIC (dynamic=false - i.e. it was pinned with
add_static_dhcp_lease), the returned preview's warning field is
non-null: removing it deletes the pinned IP<->MAC mapping itself,
not just a renewable cache entry. Always check warning before
calling again with confirm=true. No warning for a dynamic lease -
that is this tool's ordinary use case.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a preview (including the warning field) without
changing anything; call again with confirm=True to actually remove
it. Errors clearly if nothing matches.
Inputs: device_name (required), address, confirm, mac_address. Returns: object
remove_from_address_listA
Remove the entry matching list_name+address from a firewall
address-list (/ip/firewall/address-list).
Like add_to_address_list, this only manages the list - see that tool's docstring and README's "Blocking/allowing a client via address lists" section for why this alone doesn't guarantee a change in blocking/allowing behavior.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a preview of what would be removed; call again with
confirm=True to actually remove it. Errors clearly if no entry
matches list_name+address.
Inputs: address (required), list_name (required), device_name (required), confirm. Returns: object
remove_from_ipv6_address_listA
Remove the entry matching list_name+address from an IPv6
firewall address-list (/ipv6/firewall/address-list). Mirrors
remove_from_address_list on the IPv6 menu.
address must be IPv6 (an IPv4 address/subnet is rejected before
the device is ever touched).
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a preview of what would be removed; call again with
confirm=True to actually remove it. Errors clearly if no entry
matches list_name+address. Also errors clearly if the ipv6
package is disabled on the device.
Inputs: address (required), list_name (required), device_name (required), confirm. Returns: object
remove_ipv6_routeA
Remove a static IPv6 route (/ipv6/route remove), resolved by
dst_address - narrowed by gateway when more than one route
shares that dst_address. Mirrors remove_route on the IPv6 menu,
INCLUDING its most important safety property. Errors clearly if
nothing matches, or if the match is still ambiguous after
narrowing.
dst_address/gateway must be IPv6 (an IPv4 address/subnet in
either is rejected before the device is ever touched).
SAFETY: refuses outright (raises an error, does not remove
anything) if the resolved route is dynamic (dynamic=true - a
connected/DHCP/router-advertisement-installed route). Only static,
admin-created IPv6 routes can be removed by this tool.
RISK: removing the default route (dst_address="::/0") cuts all
outbound IPv6 traffic that relies on this gateway. The returned
preview's warning field is non-null whenever this is the case
(not blocking - removing a static default route is legitimate).
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview (including the warning
field) without changing anything; call again with confirm=True to
actually apply it. Also errors clearly if the ipv6 package is
disabled on the device.
Inputs: device_name (required), dst_address (required), confirm, gateway. Returns: object
remove_netwatchA
Remove a Netwatch host monitor by host or comment
(/tool/netwatch remove). At least one of host/comment must be
given and must match an existing monitor (host is tried first if
both are given).
WRITE tool, guarded: blocked entirely unless the server is running with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the default) to get a preview of what would be removed; call again with confirm=True to actually remove it.
Inputs: device_name (required), host, comment, confirm. Returns: object
remove_ppp_secretA
Remove a PPP/PPPoE secret (/ppp/secret remove) by name.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually remove it. Errors clearly
if no secret matches name (ResourceNotFoundError), or if more
than one somehow does (AmbiguousResourceError) - never guesses
which one to remove. The returned preview's before never
includes the secret's password - redacted before the preview is
ever built (see guard.remove_ppp_secret's docstring).
Inputs: name (required), device_name (required), confirm. Returns: object
remove_routeA
Remove a static route (/ip/route remove), resolved by
dst_address - narrowed by gateway when more than one route
shares that dst_address. Errors clearly if nothing matches, or if
the match is still ambiguous after narrowing.
SAFETY: refuses outright (raises an error, does not remove
anything) if the resolved route is dynamic (dynamic=true - a
connected/DHCP/OSPF/BGP-installed route). Only static,
admin-created routes can be removed by this tool - removing a
device's connected/dynamic route can sever the network.
The returned preview's warning field is non-null (but not
blocking) whenever the resolved route's dst_address is the
default route (0.0.0.0/0/::/0) - check it before calling again
with confirm=true.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview (including the warning
field) without changing anything; call again with confirm=True to
actually apply it.
Inputs: device_name (required), dst_address (required), confirm, gateway. Returns: object
remove_simple_queueA
Remove a Simple Queue by target or by name - undoes a
bandwidth limit previously set with set_client_bandwidth. At least
one of target/name must be given and must match an existing
queue.
WRITE tool, guarded: blocked entirely unless the server is running with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the default) to get a preview of what would be removed; call again with confirm=True to actually remove it.
Inputs: device_name (required), name, target, confirm. Returns: object
remove_static_dnsA
Remove a static DNS entry (/ip/dns/static remove) by name,
optionally narrowed by record_type ("A"/"CNAME").
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a preview of what would be removed; call again with
confirm=True to actually remove it. Errors clearly if nothing
matches name (narrowed by record_type), or if more than one row
still matches after narrowing (AmbiguousResourceError) - never
guesses which one to remove.
Inputs: name (required), device_name (required), confirm, record_type. Returns: object
remove_vlanA
Remove a VLAN interface (/interface/vlan remove) by name.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually remove it. Errors clearly
if no VLAN interface matches name.
Inputs: name (required), device_name (required), confirm. Returns: object
remove_wireguard_peerA
Remove a WireGuard peer (/interface/wireguard/peers remove)
from interface, resolved by public_key or comment (public_key
tried first if both are given). At least one of the two must be
given.
WRITE tool, guarded: blocked entirely unless the server is running with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the default) to preview what would be removed without changing anything; call again with confirm=True to actually remove it. Errors clearly if nothing matches, or if more than one peer still matches (AmbiguousResourceError) - never guesses which one to remove.
Inputs: interface (required), device_name (required), comment, confirm, +1 more. Returns: object
schedulerB
List scheduled tasks (/system/scheduler): name, on-event, interval, next-run, disabled. Read-only.
Inputs: device_name (required). Returns: result
security_auditA
Read-only security audit of a device's configuration - gives an LLM caller (or operator) a structured list of findings to review, so it can "look at the security of this router" without an operator manually walking every menu.
Aggregates several independent, defensive checks - see
src/mcp_mikrotik/security.py for the full list and reasoning:
insecure management services (/ip/service: telnet/ftp/www/api
enabled, and whether they're open to any address), whether the
firewall's input chain ends in a drop/reject rule (heuristic),
SNMP community exposure (/snmp/community), an open DNS resolver
(/ip/dns allow-remote-requests), outdated RouterOS
(/system/package/update), open wireless/wifi networks (no
security profile / no passphrase), a count of users with a
write/full policy, and (v1.6) an expired or soon-to-expire
(<=30 days) certificate (/certificate).
Each check reads its own menu(s) and skips itself (contributing no findings) if that menu doesn't exist on this device/RouterOS generation - one missing/unsupported menu never fails the whole audit. NEVER a scanner, NEVER definitive - this is a heuristic, best-effort read meant to prompt a human decision, not to replace one; see README's "Security audit" section for the full disclaimer.
READ-ONLY: does not change anything on the device, and is not gated by MIKROTIK_ALLOW_WRITE.
NO SECRET IS EVER RETURNED: no finding ever includes a password, passphrase, or SNMP community string - see security.py's module docstring for exactly how each check avoids that.
Returns {"findings": [{"severity", "category", "title", "detail", "recommendation"}, ...], "summary": {"high", "medium", "low", "info"}} - findings sorted by severity (high first), summary
always including all four keys (0 for a severity with no findings).
Inputs: device_name (required). Returns: object
security_eventsA
Recent RouterOS log entries filtered down to security-relevant
ones - login/logout/authentication-failure events (topic
"account"), "critical"/"error" topic entries, and generic
"system,info" rows whose message looks like a login/logout - so a
caller can correlate access attempts/anomalies without reading the
entire (often much larger) unfiltered log via logs.
Filtering happens in Python, same reasoning logs' topics filter
already documents (RouterOS's structured API doesn't expose a
query-by-field read here either) - and is applied BEFORE the
limit cut, so this returns the most recent limit MATCHING
entries (not the last limit raw entries filtered afterward, which
would silently drop matches on a busy log).
limit must be positive and is capped at 500 (default 50), the same
shape as logs' own limit.
READ-ONLY: not gated by MIKROTIK_ALLOW_WRITE.
Inputs: device_name (required), limit. Returns: result
set_client_bandwidthA
Limit a client's bandwidth via a RouterOS Simple Queue (/queue/simple).
target is the client's IP address or subnet (e.g. "10.0.0.5" or
"10.0.0.0/24"). max_limit is a RouterOS rate pair in
"upload/download" form (e.g. "10M/5M"); limit_at is the optional
guaranteed-rate (CIR) pair in the same form. If a Simple Queue
already targets target, its max-limit/limit-at is UPDATED;
otherwise a new one is CREATED with a name derived from target -
the returned operation field ("set_client_bandwidth_update" vs
"set_client_bandwidth_add") tells you which happened (or would
happen, with confirm=False).
WRITE tool, guarded: blocked entirely unless the server is running with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the default) to get a before/after preview without changing anything; call again with confirm=True to actually apply it.
GOTCHA - FastTrack: if the device has a FastTrack rule in its firewall (common on RouterOS's own quick-set wizards), fasttracked connections bypass queueing entirely, so this queue may have no visible effect on a client whose traffic is already fasttracked - see README's "Security model" section.
Inputs: target (required), max_limit (required), device_name (required), confirm, +1 more. Returns: object
set_identityA
Set a device's RouterOS identity (hostname).
WRITE tool, guarded: blocked entirely unless the server is running with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the default) to get a before/after preview without changing anything; call again with confirm=True to actually apply it.
Inputs: new_name (required), device_name (required), confirm. Returns: object
set_ntp_serversA
Set the NTP server(s) a device syncs its clock against
(/system/ntp/client). servers is a list of one or more IPv4/
IPv6 addresses or hostnames.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Never
enables/disables the NTP client itself - only the server list
changes; the returned preview's warning says so if the client is
currently disabled.
Works against either RouterOS generation, detected by reading
/system/ntp/client first: ROS7 writes the full servers list;
ROS6 has no such list - servers[0]/servers[1] map onto its fixed
primary-ntp/secondary-ntp slots instead (extras beyond two are
dropped, called out in warning). A hostname destined for one of
those two ROS6 slots is folded into server-dns-names if the device
has that field, otherwise it is not applied - warning says so. See
guard.set_ntp_servers's docstring for the full detection/mapping
rules.
Inputs: servers (required), device_name (required), confirm. Returns: object
set_poe_outA
Set a PoE-capable ethernet port's PoE output mode (/interface/ethernet set [interface_name] poe-out=<poe_out>).
poe_out must be one of "auto-on", "forced-on", "off".
Primary use case: reset a locked-up antenna/camera/AP powered over PoE by cycling its power - call with poe_out="off" (confirm=true), wait for it to actually power down, then call again with poe_out="auto-on" to bring it back up.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors clearly
(without changing anything) if interface_name doesn't exist on the
device, or exists but isn't PoE-capable - it never creates or
coerces anything.
Inputs: poe_out (required), device_name (required), interface_name (required), confirm. Returns: object
set_route_distanceA
Adjust an existing route's distance (failover priority - lower
distance wins) via /ip/route set distance=<distance>.
Resolved by the STABLE (dst_address, gateway) pair - never a
dynamic .id/list index, which can silently shift as routes are
added/removed elsewhere on the device between a preview and the
confirmed apply. Errors clearly (without changing anything) if no
route matches that pair, or if more than one still does
(AmbiguousResourceError) - this never guesses which route to
touch.
WRITE tool, guarded: blocked entirely unless the server is running with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the default) to get a before/after preview without changing anything; call again with confirm=True to actually apply it. See README's "Failover control" section for the recommended step-by-step flow.
Inputs: gateway (required), distance (required), device_name (required), dst_address (required), +1 more. Returns: object
set_wifi_ssidA
Set a wireless interface's SSID.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Works against
either RouterOS generation - it looks for interface_name under the
ROS7 wifi package first, then the ROS6 wireless package - and errors
clearly if it isn't found under either; it is never created.
On ROS7, a wifi interface running the standard production layout (a
named configuration) has no ssid field of its own - the actual
write lands on the referenced /interface/wifi/configuration profile
instead, resolved automatically. The before/after preview always
reflects the real location the ssid is read from and written to.
Inputs: new_ssid (required), device_name (required), interface_name (required), confirm. Returns: object
set_wireless_channelA
Set a /interface/wireless interface's frequency (MHz) and
optionally channel_width.
LOCKOUT-RISK on a PtP link that is itself the management path to the
far end: a bad frequency (or a DFS Channel Availability Check stall)
can cut the only route back to the device. By default
(arm_deadman=True), a confirm=True apply FIRST arms a dead-man
(arm_dead_man) that restores the interface's prior frequency/
channel-width after deadman_minutes (1-60, default 3) unless
cancelled - call cancel_dead_man(device_name, dead_man["name"])
once the new channel is confirmed good. Set arm_deadman=False only
for an interface known NOT to be a management path.
The returned preview's warning ALWAYS reports whether frequency
needs a DFS Channel Availability Check under this interface's
CURRENT frequency-mode (superchannel: none, instant; otherwise
~60s in the general DFS range 5250-5725MHz, ~600s in the
5600-5650MHz weather-radar sub-band - verified against real
hardware). See docs/api-notes-wireless-rf.md.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview (including the DFS warning,
without arming anything) without changing anything; call again with
confirm=True to actually apply it. Errors if interface_name
doesn't exist on /interface/wireless - never creates one. This
targets /interface/wireless only - see guard.py's module note for
why ROS7's newer /interface/wifi package is out of scope this
round.
Inputs: frequency (required), device_name (required), interface_name (required), confirm, +3 more. Returns: object
set_wireless_tuningA
Set a /interface/wireless interface's adaptive_noise_immunity
("none"/"client-mode"/"ap-and-client-mode") and/or distance
("dynamic"/"indoors"/an integer number of km). At least one must be
given.
adaptive_noise_immunity alone is reception-only tuning - CONFIRMED
SAFE against real hardware today (does not drop an already-
associated link), never arms a dead-man. CONFIRMED TODAY: with good
signal but poor CCQ (interference, not distance),
adaptive_noise_immunity="ap-and-client-mode" measurably helped.
A NUMERIC distance (unlike the named "dynamic"/"indoors" modes)
directly changes the ACK-timeout/TDMA timing - LOCKOUT-RISK,
CONFIRMED LIVE it can silently drop an already-associated link on a
mismatch (e.g. too short for the real link length). By default
(arm_deadman=True), a numeric distance arms a dead-man
(arm_dead_man) that restores the interface's prior distance
after deadman_minutes (1-60, default 3) unless cancelled - same
mechanism as set_wireless_channel/set_wireless_tx_power. For a
long verified PtP link, an explicit distance (e.g. 9 for ~9km)
gave a better ACK timeout than leaving it on "dynamic".
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview (including the LOCKOUT-RISK
warning for a numeric distance, without arming anything) without
changing anything; call again with confirm=True to actually apply
it. Errors if interface_name doesn't exist on /interface/wireless
never creates one.
Inputs: device_name (required), interface_name (required), confirm, distance, +3 more. Returns: object
set_wireless_tx_powerA
Set a /interface/wireless interface's tx_power (dBm),
forcing tx-power-mode=all-rates-fixed.
CONFIRMED AGAINST REAL HARDWARE TODAY: on a short link, the default
(maximum) tx-power SATURATES the receiver and PRODUCES A WORSE CCQ
than a lower power (measured: -27dBm/CCQ 34 at default, -47dBm/CCQ
94 at ~8dBm). There is no single "right" power for every link - use
get_wireless_link_quality before/after to judge the effect.
LOCKOUT-RISK for the same reason as set_wireless_channel - same
arm_deadman/deadman_minutes dead-man behavior (default armed),
restoring BOTH tx-power-mode and tx-power on revert.
The returned preview's warning always notes that CCQ/rate briefly
re-adapt (a few seconds) right after a power change - expected, not
a failure.
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors if
interface_name doesn't exist on /interface/wireless - never
creates one.
Inputs: tx_power (required), device_name (required), interface_name (required), confirm, +2 more. Returns: object
simple_queuesA
List Simple Queue entries (/queue/simple): name, target, max-limit,
limit-at, bytes counters, disabled. Use this to see which clients
already have a bandwidth limit and how much traffic they've moved
(the bytes counter), before deciding who to limit with
set_client_bandwidth.
Inputs: device_name (required). Returns: result
start_containerA
Start a container by name or tag (/container/start).
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors clearly
(without creating anything) if container doesn't match any
/container row's name or tag on the device - it is never
created.
The after.status in the preview is the status RouterOS sets
immediately ("starting"), not a guaranteed final state - the
container transitions to "running" asynchronously; use containers
again afterward to see the settled status.
Inputs: container (required), device_name (required), confirm. Returns: object
stop_containerA
Stop a container by name or tag (/container/stop).
WRITE tool, guarded: blocked entirely unless the server is running
with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the
default) to get a before/after preview without changing anything;
call again with confirm=True to actually apply it. Errors clearly
(without changing anything) if container doesn't match any
/container row's name or tag on the device - it is never
created.
The after.status in the preview is the status RouterOS sets
immediately ("stopping"), not a guaranteed final state - use
containers again afterward to see the settled status.
Inputs: container (required), device_name (required), confirm. Returns: object
system_clockA
Device clock (/system/clock): time, date, time-zone-name,
time-zone-autodetect (normalized to bool | None -
formatting.coerce_ros_bool), gmt-offset, dst-active (also
normalized) - a single-row menu, same shape as container_config/
ntp_client.
Clock drift breaks certificate validation (see certificates'
daysUntilExpiry), log timestamps, and scheduler timing - check
this alongside ntp_client's status/synced-server when
diagnosing any of those. Returns an empty dict (never an error) for
a device with no /system/clock menu at all (not expected on any
real RouterOS device, but handled the same defensive way every
other single-row read here is).
Inputs: device_name (required). Returns: object
system_healthB
Read system health metrics (e.g. voltage, temperature), if the device exposes them.
Not every RouterOS device/board type has health sensors (e.g. some CHR/virtual instances have none) - in that case this returns an empty list instead of raising.
Inputs: device_name (required). Returns: result
system_infoB
Get RouterOS identity + resource info (board, version, uptime, CPU/memory).
Inputs: device_name (required). Returns: object
torchA
Live traffic snapshot of one interface
(/tool/torch interface=<interface> once=yes) - RouterOS's own
real-time traffic monitor, useful to answer "who is consuming
bandwidth on this link RIGHT NOW". interface is validated for
shape (validate_interface_name) before it is ever sent to the
device - existence isn't checked separately, so a typo'd/unknown
interface name simply produces whatever error RouterOS itself
returns.
src_address/dst_address (plain IPv4/IPv6 addresses -
validate_ip_address) and port (1-65535 -
validate_conntrack_dst_port, reused here for the same "TCP/UDP
port" shape) are all optional filters forwarded to RouterOS itself,
narrowing the snapshot BEFORE it ever leaves the device - use them
to cut down volume on a busy interface rather than fetching every
flow and filtering client-side.
once=yes makes this a single instantaneous snapshot, not a
continuous stream (same "once" convention as interface_traffic/
poe_status/lte_status - see client.MikrotikClient.torch), so
the call always returns promptly instead of opening RouterOS's
normal interactive torch stream.
VOLUME CAP: regardless of how many flows RouterOS reports for this
snapshot, the result's flows list is sorted by total traffic
(tx+rx bytes, biggest first - the "top talkers") and hard-capped at
MAX_TORCH_LIMIT (50) entries - truncated is true whenever more
flows matched than were returned, and total_matched always reports
the real (pre-cap) count. RouterOS's own torch field names for a
flow's traffic volume aren't perfectly uniform across RouterOS
versions/hardware - this sorts by whichever of tx/rx (bits- or
bytes-per-second, depending on version) the device actually
returned, defaulting a flow with neither to 0 (sorted last) rather
than failing the whole call over one unexpected row shape.
Inputs: interface (required), device_name (required), port, dst_address, +1 more. Returns: object
tracerouteA
Traceroute to an address from a device; returns the list of hops.
address must be a valid IPv4/IPv6 address or hostname (validated
exactly like ping's). count (probes per hop) and max_hops are
both capped low (see MAX_TRACEROUTE_COUNT/MAX_TRACEROUTE_MAX_HOPS)
and a fixed short per-hop timeout is used internally, so the command
can't run long enough to hit RouterOS's own ~60s API command
timeout.
Diagnostic only - this never changes device state, so it is not gated by MIKROTIK_ALLOW_WRITE and needs no confirm/preview.
Inputs: address (required), device_name (required), count, max_hops. Returns: result
usb_devicesA
USB hardware on a device: physical USB ports
(/system/routerboard/usb, if the board exposes them) plus attached
storage (/disk - USB flash drives, and USB LTE/5G modems that
surface as a disk rather than under routerboard/usb). Combined into
one read since which of the two a given USB device shows up under
depends on the hardware.
Returns {"usb_ports": [...], "disks": [...]} - either or both
lists empty (never an error) if the board doesn't expose that
menu/hardware at all.
Inputs: device_name (required). Returns: object
user_activeA
List currently active RouterOS login sessions (/user/active):
name, address, via (e.g. api/winbox/ssh/web), when
(session start time) - who is logged into the device's own
management right now, as opposed to users' CONFIGURED accounts.
Returns an empty list (never an error) for a device with nobody
currently logged in, or if /user/active is unavailable.
Inputs: device_name (required). Returns: result
usersA
List RouterOS login accounts (/user): name, group,
address (an allowed-source restriction, if the account has one),
last-logged-in (if RouterOS exposes it), disabled, comment.
/user's own API reply never carries a password at all - RouterOS
doesn't expose it over the API, so there is nothing to strip here
(unlike ppp_secrets/radius, whose underlying menus DO carry a
secret). This is a READ only: creating/editing a /user login stays
deliberately out of scope for this package (see ROADMAP.md's
"Explicitly NOT on the roadmap" - a router login is a device/API
credential, a different risk class from a service credential like a
PPP secret or hotspot user).
Returns an empty list (never an error) if /user is unavailable for
some reason - /user always exists on RouterOS in practice, but this
keeps the same "empty, not an error" convention every other optional
read in this package uses.
Inputs: device_name (required). Returns: result
wake_on_lanA
Send a Wake-on-LAN magic packet (/tool/wol) for mac_address,
out interface.
Benign - it never changes device configuration and targets no
existing RouterOS row - but still guarded/confirm-gated like every
other write tool here, so an LLM caller can't wake a machine "by
accident". Does NOT verify interface exists on the device first;
RouterOS itself rejects an unknown interface name at send time.
WRITE tool, guarded: blocked entirely unless the server is running with MIKROTIK_ALLOW_WRITE=true. Call with confirm=False (the default) to get a preview of what would be sent without changing anything; call again with confirm=True to actually send it.
Inputs: interface (required), device_name (required), mac_address (required), confirm. Returns: object
wireguard_interfacesA
List WireGuard tunnel interfaces (/interface/wireguard): name, listen-port, public-key, running, disabled, mtu.
SECURITY: RouterOS's own /interface/wireguard reply carries the
interface's private-key - this is ALWAYS stripped before
returning (formatting.strip_sensitive_fields with
formatting.WIREGUARD_SENSITIVE_FIELDS), the same mechanism
wireguard_peers (v0.8) already used defensively for a peer's
private-key/preshared-key. A private-key never leaves this process.
See test_wireguard_interfaces_never_exposes_private_key.
Returns an empty list (never an error) for a device with no WireGuard package/interfaces at all - same convention as wireguard_peers.
Inputs: device_name (required). Returns: result
wireguard_peersA
List WireGuard VPN peers (/interface/wireguard/peers): name, interface, public-key, endpoint-address/endpoint-port, current-endpoint-address/current-endpoint-port, last-handshake, rx/tx byte counters, allowed-address, disabled.
SECURITY: a private-key field never appears in RouterOS's own
/interface/wireguard/peers reply (only /interface/wireguard - the
tunnel interfaces themselves - carries one; see wireguard_interfaces
below), but this strips it defensively anyway, along with any
preshared-key a configured peer may genuinely carry (a real, if
optional, RouterOS field on this menu) - see
formatting.WIREGUARD_SENSITIVE_FIELDS and
test_wireguard_peers_never_exposes_private_key/
test_wireguard_peers_never_exposes_preshared_key.
Returns an empty list (never an error) for a device with no WireGuard package/interfaces at all - same "empty, not an error" convention as wireless_registrations/system_health for optional features.
Inputs: device_name (required). Returns: result
wireless_registrationsA
List wireless clients currently associated to the device (mac, signal, interface, uptime).
RouterOS exposes this under two different paths depending on generation: ROS7's wifi package (/interface/wifi/registration-table) or ROS6's wireless package (/interface/wireless/registration-table). This tries ROS7 first, falls back to ROS6, and returns an empty list
rather than raising - for a device with no wireless radio at all (or the relevant package not installed), since that is a completely normal, expected state for a wired-only device.
RAW rows, as RouterOS returns them - see get_wireless_link_quality
(v1.11) for the same registration-table data normalized into a fixed
CCQ/rate/distance shape for PtP/PtMP diagnosis.
Inputs: device_name (required). Returns: result
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 114 tools
Most tools target clearly distinct resources, but the firewall rule toggles overlap heavily: enable_firewall_rule/disable_firewall_rule/enable_nat_rule/disable_nat_rule/enable_mangle_rule/disable_mangle_rule/enable_ipv6_firewall_rule/disable_ipv6_firewall_rule are nine near-identical tools distinguished only by menu, and the IPv6 mirror set (ipv6_addresses vs ip_addresses, add_route vs add_ipv6_route, etc.) requires reading descriptions carefully to pick correctly.
Overwhelmingly consistent verb_noun snake_case (add_route, remove_vlan, set_wifi_ssid, list_backups), but several read tools drop the verb entirely (interfaces, neighbors, logs, certificates, users, scheduler, radius, torch, ping, traceroute), mixing noun-only names with the dominant verb_noun pattern.
114 tools is an extreme mismatch for a single LLM-facing server: the nine enumerate/disable rule toggles, the full IPv4/IPv6 duplicated read-and-write surface, and per-feature reads (wireless, wireguard, lte, ppp, container, bgp, ospf, netwatch) collectively make the surface far larger than an agent can reliably navigate.
Coverage is genuinely broad - reads for nearly every RouterOS subsystem plus guarded CRUD for routes, VLANs, firewall rules, DNS, DHCP, WireGuard, PPP, hotspot, containers, and netwatch, with IPv6 read/write parity. Gaps exist (no interface creation, no user management, no hotspot profile creation) but these appear intentional and documented.
Maintenance
Related MCP Connectors
Secure and manage MCP servers through the Speakeasy AI Control Plane from your agent.
An MCP server that provides an API to LLMs to manage their JumpCloud resources.
MCP server for mandates, delegation, policy-gated execution, credential grants, and audit.
Related MCP Servers
- AlicenseAqualityAmaintenanceProduction-grade MCP server for MikroTik RouterOS with secure AI-native network automation.133278 npm106MIT
- AlicenseAqualityDmaintenanceMCP server for managing VyOS routers via the VyOS HTTP API, allowing AI assistants to read config, set interfaces, firewall rules, VPNs, and more.172MIT
- FlicenseNot gradedqualityDmaintenanceMCP server for MikroTik RouterOS with multi-router support, environment-based auto-load, split read/write architecture, and selectable runtime modes: readonly, safe-write, full.1-
- AlicenseNot gradedqualityDmaintenanceMCP server that sends CLI commands to MikroTik RouterOS via SSH. It allows executing any RouterOS CLI command and getting text output back.1-