mcp-mikrotik
mcp-mikrotik is an MCP server for managing MikroTik RouterOS devices over stdio, providing extensive read-only monitoring tools and a small set of guarded write operations.
Read-only tools – query device state without any risk:
list_devices,system_info,interfaces,ip_addresses,ip_routes,neighbors,logs,ping,traceroutearp_table,bridge_hosts,dhcp_leases,simple_queues,address_lists,firewall_nat,firewall_filterscheduler,ip_pools,wireless_registrations,dns_cache,system_health,interface_trafficpoe_status,lte_status,lte_interfaces,containers,container_config,usb_devices,list_write_operations
Guarded write tools – require MIKROTIK_ALLOW_WRITE=true and a two-step confirm=false (preview) → confirm=true (apply) flow:
set_identity– set the device hostnameenable_interface/disable_interface– toggle a network interfaceset_wifi_ssid– change a wireless SSID (ROS6/ROS7 compatible)set_client_bandwidth– create/update a Simple Queue to limit client bandwidthadd_static_dhcp_lease/ (no silent duplicates) – create a static DHCP leaseremove_simple_queue– remove a queue by name or targetadd_to_address_list/remove_from_address_list– manage firewall address-list entriesset_poe_out– set PoE output mode (auto-on/forced-on/off) for remote power cyclingstart_container/stop_container– manage OCI containers by name or tag
Security model:
Write-disabled by default; only an explicit allowlist of reviewed operations is permitted (no generic command execution)
All writes show a before/after diff before applying; named-resource writes fail if the resource doesn't exist (preventing typos from creating stray config)
Passwords and secrets are never returned in responses; all device communication uses a structured API (no command injection)
Production hardening:
Audit journal (structured JSON lines) for every write operation (preview, applied, or failed)
Correlation IDs on every tool call for end-to-end traceability
Automatic read retries on transient network errors
Per-device in-memory circuit breaker to fail fast on unresponsive devices
TLS/SSL support with optional CA cert or
tls_verify: falsefor self-signed certificates
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 "Install 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-mikrotikshow 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.0.0. Every planned tool round has shipped: the full read-tool
inventory (interfaces, routing, DHCP, wireless, VPN/WireGuard, containers,
LTE/5G, USB, hotspot, live traffic, backups, a heuristic security audit),
guarded writes across identity/interfaces/wifi/bandwidth/DHCP/
address-lists/PoE/containers/failover-routing/Netwatch/DNS/Wake-on-LAN/
firewall-rule-toggle/WireGuard/hotspot-vouchers/backup, 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 (what shipped in
each v0.x round), and "Roadmap & non-goals" below for what's deliberately
still out of scope, and why.
The full pytest suite currently has 988 tests, all passing against an
in-memory fake device layer (pytest -q) - see "Development" below.
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 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 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 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 ( |
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 ( |
| 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 ( |
Not yet exposed, deliberately: device reboot, backup RESTORE (/system/backup/load
same risk class as reboot), and creating/generally modifying a firewall filter rule (only the narrow
disabledTOGGLE of an existing, admin-authored rule is exposed - see "Firewall 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.
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.
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.
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.
Connection 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 seven 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.
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.
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.
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.
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_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 988 tests,
zero of which touch a real device or the network.
CI (.github/workflows/ci.yml, GitHub Actions) runs the full pytest suite
on every push to main and every pull request, against both Python 3.11 and
3.12 - both must pass before a change is considered mergeable.
Contributing: a new write tool must go through guard.py's
ALLOWLIST pattern (one named WriteOperation + one dedicated function -
see the comment block at the top of guard.py) - never a generic
"run this path" tool. Add tests alongside any new behavior (both the guard
function in tests/test_guard.py/test_guard_audit.py and the tool
registration in tests/test_server.py, following existing tests as a
template), and make sure pytest -q is green locally before opening a PR -
CI runs the identical suite.
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
MIT - see LICENSE.
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Latest Blog Posts
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/thalisantunes/mcp-mikrotik'
If you have feedback or need assistance with the MCP directory API, please join our Discord server