Language server
routeros-lsp checks RouterOS scripts (.rsc) while you write them — in an editor, in Claude Code,
or from the command line — and tells you what the router would reject: syntax errors, unknown menus, commands
and parameters, and variable-scope mistakes, with completion and hover from the RouterOS command list.
What it reports
Diagnostics are pushed when a document opens or changes. Each one carries its rule id as code
and routeros-lsp as source.
| Area | Reports |
|---|---|
| Syntax | Unterminated strings, invalid escapes, unbalanced ( ) [ ] { }, two values with no space between them, whitespace before =, a # that is not a comment, two commands on a line without ;, characters RouterOS rejects, line breaks inside ( ), a byte order mark. |
| Variables | :set of a name that is not declared in scope, reads of variables that are declared nowhere or whose scope has ended (including a local used on a later line of an imported file), invalid variable names, keywords used as names. |
| Menus and commands | An unknown menu, command or parameter with a “did you mean”, ambiguous abbreviations, repeated parameters, too many unnamed arguments, missing required parameters. The menu path is tracked the way RouterOS does it, so relative commands after /ip address are checked in that menu. |
The complete list, with default severities and sources, is on Diagnostic rules.
Install
Python 3.10 or newer, and nothing else — the server has no runtime dependencies.
$ pipx install routeros-lsp # or: uv tool install routeros-lsp $ routeros-lsp --version
$ uvx routeros-lsp --stdio
$ pipx install git+https://github.com/wiresage/routeros-lsp.git
The server speaks LSP over stdio: routeros-lsp --stdio.
Editors and Claude Code
Claude Code
The plugin/ directory of the repository is a Claude Code plugin.
It starts the server for .rsc files (language id routeros) and expects the routeros-lsp
command on your PATH. Its .lsp.json tells Claude Code how to start the server:
{
"routeros": {
"command": "routeros-lsp",
"args": ["--stdio"],
"extensionToLanguage": { ".rsc": "routeros" },
"startupTimeout": 15000
}
}
Settings go into the same file, under initializationOptions (see Configuration):
{
"routeros": {
"command": "routeros-lsp",
"args": ["--stdio"],
"extensionToLanguage": { ".rsc": "routeros" },
"initializationOptions": {
"scope": "script",
"severities": { "vars/undeclared-read": "off" }
}
}
}
Any LSP client
Start the command below for the language id routeros from any LSP client.
| Setting | Value |
|---|---|
| Command | routeros-lsp |
| Arguments | --stdio |
| Transport | stdio, JSON-RPC with Content-Length framing |
| Language id | routeros |
| File extension | .rsc |
The server writes nothing but protocol messages to stdout; log output goes to the client through window/logMessage.
What the editor gets
| Request | Provides |
|---|---|
textDocument/completion | Menus and commands at the start of a line (global commands after :), parameters of the command being written (required ones first, flags without =), known values after name=, variables in scope after $, also inside strings. |
textDocument/hover | Description of a menu, command or parameter from the command database, with type, values and required parameters; where a variable was declared. |
textDocument/documentSymbol | Functions (:global name do={ }), variables declared with :global and :local, and the menu path headers that divide an export into sections. |
textDocument/foldingRange | Blocks, commands continued with a backslash, multi-line strings, runs of comment lines. |
Check from the command line
The same analysis runs without an editor — for a pre-commit hook, a CI job, or a script an AI agent just wrote.
A directory means every .rsc file below it, and - means standard input.
$ routeros-lsp check backup.rsc scripts/ # files and directories $ routeros-lsp check --strict --format json export.rsc $ cat export.rsc | routeros-lsp check - # standard input
Take this script:
# Guest VLAN :local vlanId 200 /interface vlan add name=vlan200 vlan-id=$vlanId interface=ether1 /ip adress add address=192.168.200.1/24 interface=vlan200 /ip pool add name=pool-200 ranges=192.168.200.10-192.168.200.100 /ip firewall nat add chain=srcnat action=masquerade # WAN :set vlanId 201
Findings are printed as path:line:column: severity: message [rule], in file order. The summary goes to
stderr, so stdout can be piped. Columns count characters, from 1.
$ routeros-lsp check guest-vlan.rsc guest-vlan.rsc:3:42: warning: $vlanId is a local variable of an earlier line … [vars/local-out-of-scope] guest-vlan.rsc:4:5: warning: 'adress' is not a menu or command in /ip of the RouterOS 7.21.4 command database. Did you mean 'address'? … [schema/unknown-command] guest-vlan.rsc:6:53: error: This command takes no unnamed arguments, and a '#' only starts a comment … [schema/unexpected-argument] guest-vlan.rsc:7:6: error: ':set vlanId' needs 'vlanId' to be declared first … [vars/set-undeclared] Checked 1 file: 2 errors, 2 warnings. $ echo $? 1
With --format json the output is an array with one object per finding, carrying
path, line, column, endLine, endColumn,
severity, rule, source and message.
Exit status
| Status | Meaning |
|---|---|
| 0 | Nothing to fix. |
| 1 | Errors were found. With --strict, warnings count too. |
| 2 | A file cannot be read, or an option is wrong. The other files are still checked. |
Options
The settings of the editor integration are command-line options, and go through the same validation. A wrong value is an error with exit status 2 instead of a line in a log.
| Option | What it does |
|---|---|
--strict | Warnings fail the run too (exit status 1). |
--format json | JSON array instead of text lines. |
--scope import|script | How variable scope is judged. See below. |
--schema bundled|off|FILE | The command database; off turns the command checks off. |
--severity RULE=LEVEL | Change or silence one rule. Repeatable. |
--context-variable NAME | A variable RouterOS provides to the script. Repeatable. |
routeros-lsp check --help lists them all. In CI, add --strict so warnings fail the job too.
Configuration
The server reads its settings from initializationOptions in the initialize request and from
settings in workspace/didChangeConfiguration. The object can be given bare or nested under a
routeros key. A change takes effect at once and every open document is checked again; anything the server
does not recognise is reported on the client's log and ignored.
| Setting | Values | Default | What it does |
|---|---|---|---|
severities | object of rule id to error, warning, information, hint or off | {} | Changes the severity of a rule or turns it off. See Diagnostic rules. |
scope | import or script | import | How variable scope is judged. See below. |
contextVariables | array of names | [] | Variables RouterOS provides to the script, so they are not reported as undeclared. The DHCP lease script variables leaseBound, leaseServerName, leaseActMAC and leaseActIP are always included. |
schema | bundled, off or the path of a file | bundled | The command database used to check and complete menus, commands and parameters. |
Scope: import or script
| Scope | Use it for |
|---|---|
import default | A file that is imported with /import, or pasted into the terminal. Every line is a scope of its own: commands separated by ; on the same line share it, and a block or a backslash join keeps its commands on one line. A :local on one line is gone on the next, and :set of it is a syntax error. |
script | The source of a /system script, a scheduler or a netwatch: one scope for the whole text. |
Severities
schema/unknown-command and schema/unknown-parameter are warnings by default, because the bundled
database comes from a RouterOS CHR and some menus and parameters exist only on real hardware. Once you have a database
generated from your own router (below), you can make them errors:
{ "severities": { "schema/unknown-command": "error", "schema/unknown-parameter": "error" } }
The command database
Menus, commands and parameters come from a database generated from a RouterOS 7.21.4 CHR with /console/inspect.
A CHR has no wireless, LTE or switch-chip packages. To cover your own hardware, generate the database from your router —
nothing is executed or changed on it; the tool reads /console/inspect and compiles probe lines with
/import … dry-run. It takes a few minutes.
$ pip install paramiko $ ROUTEROS_PASSWORD=... python tools/gen_schema.py --host 192.0.2.1 --user admin --out my-router.json.gz
Then point the schema setting at the file:
{ "schema": "/path/to/my-router.json.gz" }
How it works
| Stage | What it does |
|---|---|
| Parser | Hand-written, scannerless recursive descent, because RouterOS lexes by context: # is a comment only where a command starts, and { } is a block or an array depending on where it stands. It recovers from errors, so the rest of the file is still analysed. |
| Binder | Scopes, declarations and variable references, with the scope rules RouterOS applies: a scope per line in an imported file, and function bodies that see nothing of the code around them. |
| Checkers | Variable rules, and command rules that track the menu path through the file as RouterOS does and resolve every word of a command line against the command database. |
| Server | LSP over stdio. A burst of edits is analysed once, for the newest text, and a document's analysis is cached by its revision and the settings. |
Analysing a file takes about 0.3 s per 5,000 lines and 1.5 s per 20,000 lines on a laptop. The architecture notes have the rest.
Accuracy and limits
RouterOS can check a script without running it: /import file.rsc verbose=yes dry-run reports every error
with line and column and executes nothing. That is the test oracle for the project. More than 800 recorded router answers
are replayed by tests/conformance/, and the checker agreed with RouterOS 7.21.4 on 16,900 command lines
generated from the database without reporting a single one that the router accepts. It does miss about 780 that the router
rejects, almost all of the kinds below.
What is not checked
- Parameter values. RouterOS rejects some when it compiles — a bad value for a yes/no or enumerated parameter, a malformed item number. The server accepts what the router rejects here; it does not report what the router accepts.
- Flags written with a value (
print detail=yes) are reported only for commands that take no unnamed arguments, because the database cannot tell a flag from a parameter for the others. - Properties in a
whereexpression. - Hardware you don't have in the database. Menus of packages a CHR lacks, and parameters that exist only on some hardware, are missing from the bundled database.
Not yet
Go to definition, references, rename, signature help, semantic tokens, code actions, formatting, several RouterOS versions
at once, and following /import into other files. The plan is in
proposal.md.
With MikroTik MCP
Typed tools cover the operations in the API reference. When a change is better shipped as a
reviewable .rsc — a whole site config, an export you edited — check it first, then apply it through the same assistant.
.rsc script.routeros-lsp check.mikrotik_upload_file, then mikrotik_import_configuration.In Claude Code you can have both: add the MCP server (Installation) and the plugin above, then ask:
Write the guest VLAN as guest-vlan.rsc. Run routeros-lsp check on it and fix what it
reports, then upload it to the router and import it.