mikrotik-mcp
# mikrotik-mcp
[](https://www.npmjs.com/package/@denver6000/mikrotik-mcp)
A [Model Context Protocol](https://modelcontextprotocol.io) server that runs RouterOS commands on MikroTik routers over SSH.
Node + TypeScript, no runtime toolchain of its own, and **stateless**: every tool call re-reads the config and opens and closes its own SSH connection. Nothing is cached, pooled or carried between calls.
It is also **self-contained**. A profile declares everything it needs, relative paths resolve against the config file, and nothing is inherited from the machine it happens to run on — no ssh-agent, no `~/.ssh/config`, no ambient environment. Copy the config directory to a server or a container and it behaves identically.
It speaks JSON-RPC over stdio, so it drops into any MCP harness: Claude Code, Codex, opencode, Cursor, Zed, and anything else that can spawn a command.
> **Status:** the config, policy and SSH layers are covered by tests, including real SSH handshakes against a local test server. It has **not yet been run against physical MikroTik hardware** — run `mikrotik-mcp --test <profile>` against your own router first. Please report anything RouterOS does differently.
## Use it
```bash
npx -y @denver6000/mikrotik-mcp
```
The published package is plain Node ESM with no runtime toolchain of its own — Node 18+ is enough.
| Client | How to add it |
| ------ | ------------- |
| Claude Code | `claude mcp add mikrotik -- npx -y @denver6000/mikrotik-mcp` |
| Codex | `[mcp_servers.mikrotik]` in `~/.codex/config.toml` with `command = "npx"`, `args = ["-y", "@denver6000/mikrotik-mcp"]` |
| opencode | `"mikrotik": { "type": "local", "command": ["npx", "-y", "@denver6000/mikrotik-mcp"], "enabled": true }` under `"mcp"` in `opencode.json` |
| Anything else | `"mikrotik": { "command": "npx", "args": ["-y", "@denver6000/mikrotik-mcp"] }` under `"mcpServers"` |
If a profile uses password auth, the client also has to pass the variable holding it — see [Getting a secret to the server](#getting-a-secret-to-the-server).
## Quick start
### 1. Prepare the router
Create a dedicated RouterOS user rather than reusing `admin`. A read-only group is the real safety mechanism — the server's own `readOnly` check is only a guardrail.
```routeros
/user group add name=mcp-read policy=ssh,read,test
/user add name=mcp-readonly group=mcp-read
/ip service enable ssh
```
Then give it a key (preferred) or a password:
```routeros
# key auth — upload your public key first, e.g. with scp
/user ssh-keys import public-key-file=id_mikrotik.pub user=mcp-readonly
# or password auth
/user set mcp-readonly password="..."
```
### 2. Write a config file
Start from [`config.example.json`](config.example.json). For a first router, put this at `~/.mikrotik-mcp.json`:
```json
{
"profiles": {
"core": {
"host": "10.0.0.1",
"username": "mcp-readonly+ct",
"auth": { "type": "key", "path": "./keys/mikrotik" },
"description": "Core router"
}
}
}
```
`./keys/mikrotik` resolves against **the config file's own directory**, not the working directory — so a folder holding the config and its keys can be moved anywhere and still work.
Everything else defaults: port 22, `known_hosts` verification, read-only, 20 s timeout. The `+ct` suffix tells RouterOS to drop colour codes and paging.
`auth` has no default and must be declared. That is deliberate: guessing at an agent or a conventionally-named key would make the config depend on the machine it runs on, which is exactly what this server avoids.
### 3. Trust the host key
The first connection will be refused, on purpose, because the router is not in `known_hosts` yet. Either add it the normal way:
```bash
ssh-keyscan -H 10.0.0.1 >> ~/.ssh/known_hosts
```
…or, if you prefer to pin the key in the config, run the command once and copy the fingerprint out of the error message into `hostKey.fingerprintSha256`. Verify the fingerprint against the router itself (`/ip ssh print` shows the host key, or read it on the console) before trusting it.
### 4. Check it
```bash
npx -y @denver6000/mikrotik-mcp --check-config
```
```
Config search path (highest precedence first):
C:\work\.mikrotik-mcp.json
loaded C:\Users\you\.mikrotik-mcp.json
Profiles (1):
core
ssh mcp-readonly+ct@10.0.0.1:22
auth agent
host key known-hosts
writes refused (readOnly)
from C:\Users\you\.mikrotik-mcp.json
```
This exits non-zero if a file is unparseable or no profiles were found, so it works in CI too. It reads the same files the server does, but connects to nothing.
### 5. Prove it against the router
`--check-config` never connects to anything. To test the whole path — TCP, host key, credentials, RouterOS — run:
```bash
npx -y @denver6000/mikrotik-mcp --test core
```
```
Connecting to 'core' (mcp-readonly+ct@10.0.0.1:22) as agent...
host key SHA256:9lK… (accepted by policy 'known-hosts')
command /system identity print
exit code 0
took 412ms
name: MikroTik-core
OK.
```
It runs one harmless read (`/system identity print`), prints `FAILED.` with the reason if anything goes wrong, and exits non-zero. Do this before wiring up an agent — it tells you whether a problem is in the config, the network, or the router.
### 6. Point your agent at it
Ask it to run `/system resource print` on `core`. It should call `mikrotik_list_profiles` first, then `mikrotik_exec`.
## Configuration
`defaults` applies to every profile in the same file; a profile overrides it field by field.
```json
{
"$schema": "https://raw.githubusercontent.com/denver6000/mikrotik-mcp/main/schema/mikrotik-mcp.schema.json",
"defaults": {
"username": "mcp-readonly+ct",
"auth": { "type": "agent" },
"timeoutMs": 20000
},
"profiles": {
"core": {
"host": "10.0.0.1",
"description": "Core router, main uplink",
"tags": ["core", "site-a"]
},
"branch-a": {
"host": "192.168.50.1",
"port": 2222,
"tags": ["branch", "site-b"],
"hostKey": { "policy": "pinned", "fingerprintSha256": "SHA256:abc..." }
},
"lab": {
"host": "10.20.0.1",
"username": "admin+ct",
"auth": { "type": "password", "passwordEnv": "MIKROTIK_LAB_PASSWORD" },
"readOnly": false
}
}
}
```
Adding `$schema` gives you completion and validation in any editor that speaks JSON Schema.
### Profile fields
| Field | Default | Meaning |
| ----- | ------- | ------- |
| `host` | — | Hostname or IP. Required. |
| `port` | `22` | SSH port. |
| `username` | `"admin"` | RouterOS user. Append `+ct` (`"admin+ct"`) to turn off colour and paging in RouterOS output. |
| `auth` | — | **Required** (on the profile or in `defaults`). See [Authentication](#authentication). |
| `hostKey` | `{ "policy": "known-hosts" }` | See [Host key verification](#host-key-verification). |
| | | Relative paths in `auth.path` and `hostKey.knownHostsPath` resolve against the config file's directory. |
| `readOnly` | `true` | Refuse commands that are not recognised as read-only. |
| `timeoutMs` | `20000` | Connect + command timeout. |
| `algorithms` | — | SSH algorithm overrides for older routers. See below. |
| `description`, `tags` | — | Free-form, searchable by `mikrotik_list_profiles`. |
### Where the config is read from
Every one of these is loaded, highest precedence first. When two files define the same profile name the earlier one wins; profiles with different names merge into one list.
| # | Path |
| - | ---- |
| 1 | `$MIKROTIK_MCP_CONFIG` — one or more explicit paths, separated by `:` (`;` on Windows) |
| 2 | `.mikrotik-mcp.json` in the working directory, then each parent directory |
| 3 | `%APPDATA%\mikrotik-mcp\config.json` (Windows) |
| 4 | `$XDG_CONFIG_HOME/mikrotik-mcp/config.json`, else `~/.config/mikrotik-mcp/config.json` |
| 5 | `~/.mikrotik-mcp.json` |
Which to use:
- **One personal file** — `~/.mikrotik-mcp.json`. Fine for most people; start here.
- **Per-repo file** — `.mikrotik-mcp.json` committed alongside a network-automation repo, so anyone cloning it gets the right profiles. Safe to commit: it holds no secrets. Because parents are searched too, a file at the root of a monorepo covers every package under it.
- **Both** — the repo file wins for names it defines, and your personal file supplies the rest. This is how you override one router's settings locally without editing a shared file.
- **An explicit path** — `$MIKROTIK_MCP_CONFIG` for CI, or to keep several environments in separate files: `MIKROTIK_MCP_CONFIG=prod.json:staging.json`.
Files are re-read on every tool call, so edits take effect without restarting the server or the agent. `mikrotik_list_profiles` reports which file each profile came from and flags any it could not parse.
### Authentication
Every profile declares an `auth` block. There is no implicit fallback.
```jsonc
{ "type": "key", "path": "./keys/mikrotik" } // recommended
{ "type": "key", "path": "./keys/mikrotik", "passphraseEnv": "MTK_KEY_PASS" }
{ "type": "password", "passwordEnv": "MIKROTIK_LAB_PASSWORD" }
{ "type": "agent" } // opt-in; see the caveat below
{ "type": "agent", "socket": "/run/user/1000/ssh-agent" }
```
Secrets never go in the config file. A profile names the *environment variable* holding a password or passphrase, and the value is read at connect time — so config files stay safe to commit and share.
Paths expand `~` and `$VARS`; anything still relative resolves against the config file's directory.
#### Key formats
ssh2 does not accept every key file OpenSSH can. Verified against ssh2 1.17:
| Format | Works |
| ------ | ----- |
| Anything `ssh-keygen` produces — ed25519, RSA, ECDSA, encrypted or not | **yes** |
| RSA PKCS#1 PEM (`BEGIN RSA PRIVATE KEY`), encrypted or not | yes |
| EC SEC1 PEM (`BEGIN EC PRIVATE KEY`) | yes |
| **PKCS#8 PEM** (`BEGIN PRIVATE KEY`) — any key type, including ed25519 | **no** |
In practice: generate keys with `ssh-keygen -t ed25519` and you will never hit this. If you have a PKCS#8 key, `--check-config` names the problem and prints the conversion command rather than failing at connect time with "Unsupported key format".
#### Encrypted keys and the agent
Without an agent, an encrypted key's passphrase has to come from an environment variable — which runs into the subprocess problem below. So a self-contained setup in practice means **a dedicated, unencrypted key file**.
That is the standard automation answer, and it is safe here only because the blast radius is bounded elsewhere: the key belongs to a RouterOS user in a read-only group, it is used for nothing else, and it sits with `chmod 600`. On POSIX systems `--check-config` warns if the file is readable by group or others.
Agent auth remains supported for anyone who prefers it, but it is opt-in and not the default. It ties the profile to the environment the server was launched from: `SSH_AUTH_SOCK` is not always inherited by a subprocess spawned from a GUI application, and there is no agent at all in a typical container.
#### Getting a secret to the server
Your MCP client launches this server as a **subprocess**, so a variable exported in your interactive shell does not necessarily reach it. Either use agent or key auth — which need no variables, and are the reason `agent` is the default — or declare the variable in the client's own config. The field differs per client:
```bash
# Claude Code
claude mcp add mikrotik --env MIKROTIK_LAB_PASSWORD=... -- npx -y @denver6000/mikrotik-mcp
```
```toml
# Codex — ~/.codex/config.toml
[mcp_servers.mikrotik]
command = "npx"
args = ["-y", "@denver6000/mikrotik-mcp"]
env = { MIKROTIK_LAB_PASSWORD = "..." }
```
```json
// opencode — note "environment", not "env"
{
"mcp": {
"mikrotik": {
"type": "local",
"command": ["npx", "-y", "@denver6000/mikrotik-mcp"],
"enabled": true,
"environment": { "MIKROTIK_LAB_PASSWORD": "..." }
}
}
}
```
```json
// generic mcpServers config
{
"mcpServers": {
"mikrotik": {
"command": "npx",
"args": ["-y", "@denver6000/mikrotik-mcp"],
"env": { "MIKROTIK_LAB_PASSWORD": "..." }
}
}
}
```
Note the trade-off: this moves the password out of the router config and into the client config, which is usually not a file you want to commit either. Key or agent auth avoids the problem entirely.
### Host key verification
On by default. A router whose key is unknown is refused, and the error prints the fingerprint so you can verify it and then trust it.
```jsonc
{ "policy": "pinned", "fingerprintSha256": "SHA256:abc..." } // self-contained; preferred
{ "policy": "known-hosts" } // default
{ "policy": "known-hosts", "knownHostsPath": "./known_hosts" }
{ "policy": "insecure-ignore" } // accept anything — lab use only
```
Under the default `known-hosts` policy, a `known_hosts` file **beside the config** is consulted first, then `~/.ssh/known_hosts` as a fallback. Naming `knownHostsPath` explicitly uses that file only. For a config that must be portable, either ship a `known_hosts` next to it or pin the fingerprint — the personal-file fallback is a convenience for a workstation, not something to depend on. `--check-config` prints exactly which files will be consulted.
Setting `fingerprintSha256` without a `policy` implies `pinned`. Hashed `known_hosts` entries, wildcards, `[host]:port` entries and negated patterns are all handled. A host that is known but presents a *different* key is a hard failure, never a prompt.
### Older routers: legacy crypto
Modern SSH clients no longer enable the key exchange and host key algorithms that older RouterOS releases — or routers with *strong crypto* disabled — may be limited to. If the connection fails during handshake rather than at login, add what that router needs:
```json
{
"host": "10.0.0.1",
"algorithms": {
"kex": { "append": ["diffie-hellman-group14-sha1"] },
"serverHostKey": { "append": ["ssh-rsa"] }
}
}
```
`append`, `prepend` and `remove` are passed through to the SSH layer, as are plain arrays if you want to specify the whole list. Prefer upgrading RouterOS or enabling strong crypto on the router over weakening the client.
### Read-only by default
Profiles are `readOnly: true` unless you say otherwise. Commands are classified before connecting: a command must contain a recognised read action (`print`, `get`, `find`, `export`, `monitor`, `ping`, …) and no recognised write action (`set`, `add`, `remove`, `reboot`, …). Every command in a `;`- or newline-separated chain is checked, so a read cannot smuggle a write along with it. Anything the classifier does not understand is treated as a write and refused.
> This is a guardrail against accidents, **not a security boundary**. A determined agent, or a command phrased in a way the classifier does not model, can get past it. For real enforcement, log in as a RouterOS user whose group grants read access only — the router is the only thing that can enforce that. Set `readOnly: false` on profiles where writes are intended.
### Troubleshooting
| Symptom | Cause |
| ------- | ----- |
| `No profiles found` | The config is not on the search path. Run `--check-config` — it prints every path it checked. |
| A path shows `FAILED` | The file exists but is invalid JSON or breaks the schema; the reason is printed under *Problems*. Unknown keys are rejected, so check for typos like `porrt`. |
| `Host key for … is not in …known_hosts` | Expected on first contact. Verify the printed fingerprint, then add it to `known_hosts` or pin it. |
| `HOST KEY CHANGED` | The router presented a different key than last time. Do not bypass this until you know why. |
| `Environment variable … is not set` | The client did not pass it to the subprocess — see [Getting a secret to the server](#getting-a-secret-to-the-server). |
| `Refused: profile … is read-only` | Intended. Set `readOnly: false` on that profile if writes are wanted. |
| `declares no "auth"` | Add an `auth` block to the profile or to `defaults`; there is no implicit default. |
| `unsupported key format` | PKCS#8 PEM. Convert it, or regenerate with `ssh-keygen -t ed25519`. |
| `encrypted, but no "passphraseEnv"` | Either set `passphraseEnv`, or use an unencrypted dedicated key. |
| `All configured authentication methods failed` | Wrong user, wrong key, or the RouterOS group lacks the `ssh` policy. |
| Handshake fails before any login prompt | Algorithm mismatch with an older router — see [Older routers: legacy crypto](#older-routers-legacy-crypto). |
| A command fails but `exit code` is 0 | RouterOS often reports errors in its output rather than through the exit status, so read the text, not just the status. |
| Output is full of escape codes | Add `+ct` to the username (`"admin+ct"`). |
## RouterOS reference
Four documents ship with the package and back `mikrotik_search_docs`:
| Document | Covers |
| -------- | ------ |
| `console.md` | Output control (`detail`, `brief`, `count-only`, `without-paging`), selecting items with `find`, command chaining, reading single values |
| `reading-state.md` | Read commands by area — system, interfaces, addresses, routes, firewall, DHCP, wireless, logs, health, export |
| `v6-vs-v7.md` | What changed: OSPF and BGP redesign, routing filter rule syntax, moved menus, the three wireless menus, containers |
| `making-changes.md` | Write patterns, predicates over numbers, lockout risks, rule ordering, reversibility |
Content is written for this project rather than copied from MikroTik, and each claim was checked against the official documentation. Where something is widely used but could not be confirmed — `print terse` and `print as-value` — it is labelled as unverified rather than presented as fact. Verify those against your hardware.
### The trap worth knowing
RouterOS item numbers are assigned per session and reassigned on the next `print`. Since every `mikrotik_exec` call is its own SSH session, **a number from one call is meaningless in the next** — and acting on a stale number does not error, it acts on whatever holds that number now. Always select by predicate:
```
/ip firewall filter remove [find where comment="mcp-temp"]
```
This is stated in the `mikrotik_exec` tool description as well as the reference, because it is the most expensive thing an agent can get wrong here.
## Command line
| Command | Purpose |
| ------- | ------- |
| `mikrotik-mcp` | Start the MCP server on stdio (what your client runs). |
| `mikrotik-mcp --check-config` | Show every config path checked, what loaded, and what each profile resolves to. Connects to nothing. Exits non-zero on a bad or empty config. |
| `mikrotik-mcp --test <profile>` | Connect to that router and run one harmless read. Exits non-zero on failure. |
| `mikrotik-mcp --version`, `--help` | As expected. |
## Tools
### `mikrotik_list_profiles`
Search the configured routers. Returns connection details, which config file each came from, whether it is read-only, and any config problems. Never returns secrets — only the name of the variable holding one.
| Input | Type | Notes |
| ----- | ---- | ----- |
| `query` | string, optional | Case-insensitive substring over name, host, description and tags. |
### `mikrotik_search_docs`
Search bundled RouterOS reference material — command syntax, idioms, and v6/v7 differences. Offline and read-only; it touches no router and needs no config.
| Input | Type | Notes |
| ----- | ---- | ----- |
| `query` | string | Keywords, e.g. `firewall filter print`, `bgp peer v7`. |
| `version` | `6` \| `7`, optional | Limit to sections that apply to that major version. |
| `limit` | number, optional | Sections to return. Default 3. |
The reference lives in [`docs/`](docs/) as plain markdown and is re-read on every call, so it can be edited or extended without rebuilding. Ranking is BM25 over sections, with no dependencies and no index to keep in sync.
### `mikrotik_exec`
Run one RouterOS command on one router and return its output.
| Input | Type | Notes |
| ----- | ---- | ----- |
| `profile` | string | Profile name. |
| `command` | string | e.g. `/system resource print`. |
| `timeoutMs` | number, optional | Overrides the profile's timeout. |
Returns stdout, stderr, the exit code and the host key fingerprint, as text and as structured content. Because each call is a fresh connection with no shell state, commands must use absolute RouterOS paths (`/ip address print`, not `print` after a `cd`).
## Develop
No extra toolchain: Node runs the TypeScript sources directly via built-in type stripping, so `npm install` is the whole setup. **Node 22.18+ is required for development**; the published package supports Node 18+.
```bash
npm install
npm run dev # run the server on stdio, straight from src/
npm test # unit tests + a real SSH round-trip against a local ssh2 server
npm run typecheck
npm run schema # regenerate schema/mikrotik-mcp.schema.json from the zod schema
npm run build # emit dist/ with tsc
npm run inspect # open the MCP Inspector against src/
```
### Layout
```
src/
index.ts CLI entry: arg handling + stdio transport
server.ts createServer() — registers every tool
config/
schema.ts zod schema for the config file, and the resolved profile type
paths.ts where config files are looked for; ~ and $VAR expansion
load.ts read, validate, merge, search
report.ts the --check-config report
docs/
corpus.ts parse docs/*.md into searchable sections
search.ts dependency-free BM25 ranking
ssh/
exec.ts one connection, one command, then close
knownHosts.ts known_hosts parsing and host key verification
policy.ts read-only command classification
keyFile.ts offline key inspection: format, fingerprint, permissions
testConnection.ts the --test <profile> probe
tools/
listProfiles.ts mikrotik_list_profiles
exec.ts mikrotik_exec
searchDocs.ts mikrotik_search_docs
docs/ the RouterOS reference, shipped with the package
scripts/
generate-schema.ts zod schema -> JSON Schema
test/ node:test suites; helpers.ts builds a sandboxed config + env
```
Every entry point takes an injectable `LookupEnvironment` (`env`, `cwd`, `home`, `platform`) so tests never touch the real user's config or SSH agent.
### Adding a tool
1. Add `src/tools/<name>.ts` exporting a `register…Tool(server, e)` function.
2. Call it from `createServer()` in `src/server.ts`.
3. Add a case to `test/server.test.ts`.
Relative imports use explicit `.ts` extensions so Node can run the sources as-is; `tsc` rewrites them to `.js` on build. Keep `stdout` clean — it is the JSON-RPC channel. Log to `stderr` only.
## Release
The npm package is `@denver6000/mikrotik-mcp`. It is scoped because the unscoped `mikrotik-mcp` is taken by an unrelated project; the installed command is still `mikrotik-mcp`.
`version` lives in both `package.json` and `SERVER_VERSION` in `src/server.ts`; bump both. A test fails if they disagree, and the release workflow fails if the git tag disagrees with either.
### First publish
Trusted publishing cannot do a package's first release: a trusted publisher is configured on a package that already exists. So one release goes out from a workstation, and every release after that is automated.
It does **not** need an access token. npm warns against tokens with 2FA bypass for automation, and they are unnecessary here — modern npm authenticates in the browser, which works with a passkey, Windows Hello, Touch ID or a security key:
```bash
npm logout # clears any stored _authToken from ~/.npmrc
npm login # opens a browser; npm 9+ defaults to auth-type=web
npm whoami # must print denver6000 — the scope has to match
npm publish
```
A stored `_authToken` in `~/.npmrc` is the usual reason this fails with
`E403 ... Two-factor authentication or granular access token with bypass 2fa enabled is required`:
npm presents the token instead of starting an interactive login, and a token without 2FA bypass cannot publish. `npm logout` removes it, after which the browser flow takes over.
If your second factor is an authenticator app rather than a passkey, `npm publish --otp=123456` also works.
### Then switch CI on (once)
On npmjs.com, open the package → **Settings** → **Trusted publisher**, and add a GitHub Actions publisher:
| Field | Value |
| ----- | ----- |
| Repository | `denver6000/mikrotik-mcp` |
| Workflow filename | `release.yml` |
After that, every later release is a tag push, with no npm token stored anywhere:
```bash
npm version patch # or minor / major — commits and tags
git push --follow-tags
```
[`.github/workflows/release.yml`](.github/workflows/release.yml) then runs typecheck, tests and build, checks the tag against `package.json`, and publishes over OIDC. Provenance is attached automatically, so the package page links back to the exact commit and workflow run that built it.
Only `dist/`, `docs/`, `schema/`, `README.md` and `LICENSE` are published.
## License
MIT
TDQS
Scored across 3 tools
Each tool has a clearly distinct role: mikrotik_exec runs commands against the router, mikrotik_search_docs searches offline documentation, and mikrotik_list_profiles discovers configured profiles. There is no overlap in purpose and the descriptions reinforce the intended call order (list_profiles → search_docs → exec).
All names share the mikrotik_ prefix and are snake_case, with search_docs and list_profiles following a clean verb_noun pattern. mikrotik_exec deviates slightly by using a bare verb with no noun object, but the convention is otherwise predictable.
Three tools is on the lean side, but this is intentional and well-scoped: a single generic exec tool subsumes arbitrary RouterOS commands, so no per-command tools are needed. Anything less (e.g. dropping the docs or profile helper) would hurt usability, so the count is justified.
The lifecycle is covered end to end: discover profiles, look up syntax, then execute (read or write) with read-only safety enforced. Minor gaps exist, such as no batch/multi-command execution or profile management, but agents can work around these using the generic exec tool.