@usex/mikrotik-mcp 3.32.1 → 3.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,6 +63,9 @@ in memory and auto-reverts if your session drops, so you can't lock yourself out
63
63
  - 🖧 **Multiple devices** — define named routers and the AI targets one per call
64
64
  (a validated `device` argument). Configure **both ends of a tunnel** from one
65
65
  conversation. See **[docs/multi-device.md](docs/multi-device.md)**.
66
+ - 🪜 **SSH jump hosts** — reach a router with no exposed port by tunnelling
67
+ through another via `jumpVia` (ProxyJump/bastion) — commands, Safe Mode and
68
+ file upload all ride the hop. No new WAN port.
66
69
  - 🤖 **Guided prompts** — 9 built-in workflows (harden, diagnose, guest Wi-Fi, VPNs,
67
70
  cross-device tunnels, backup & document) that turn an intent into tool calls.
68
71
 
@@ -179,6 +182,17 @@ drives the whole both-ends flow. Full guide: **[docs/multi-device.md](docs/multi
179
182
  // create_wireguard_interface { "device": "site-a", "name": "wg-to-b", "listen_port": 13231 }
180
183
  ```
181
184
 
185
+ **Behind a bastion?** Reach a router with no exposed SSH port by jumping through
186
+ another (OpenSSH-style ProxyJump) — `jumpVia` names a configured device to tunnel
187
+ through; commands, Safe Mode and SFTP upload all ride the hop:
188
+
189
+ ```jsonc
190
+ "home-ax3": { "host": "10.10.30.100", "username": "admin", "jumpVia": "hex" }
191
+ ```
192
+
193
+ The bastion router needs SSH TCP forwarding enabled (`/ip ssh set
194
+ forwarding-enabled=local`). See **[docs/multi-device.md](docs/multi-device.md#ssh-jump-hosts-bastion--proxyjump)**.
195
+
182
196
  ## Built-in prompts
183
197
 
184
198
  MCP **prompts** are one-click guided workflows. This server ships 9 — authored as
@@ -220,20 +234,21 @@ See **[docs/safe-mode.md](docs/safe-mode.md)**.
220
234
  Connection and transport settings come from `MIKROTIK_*` env vars or matching CLI
221
235
  flags (highest precedence last: defaults → env → flags).
222
236
 
223
- | Variable | Flag | Default | Purpose |
224
- | ----------------------------- | ------------------ | ----------- | ----------------------------------------------------------------- |
225
- | `MIKROTIK_HOST` | `--host` | `127.0.0.1` | RouterOS host |
226
- | `MIKROTIK_USERNAME` | `--username` | `admin` | SSH user |
227
- | `MIKROTIK_PORT` | `--port` | `22` | SSH port |
228
- | `MIKROTIK_PASSWORD` | `--password` | — | SSH password _(or use a key →)_ |
229
- | `MIKROTIK_KEY_FILENAME` | `--key-filename` | — | SSH private-key file path |
230
- | `MIKROTIK_PRIVATE_KEY` | `--private-key` | — | Inline private key (PEM) |
231
- | `MIKROTIK_KEY_PASSPHRASE` | `--key-passphrase` | — | Passphrase for an encrypted key |
232
- | `MIKROTIK_CONFIG_FILE` | `--config` | — | JSON file of named devices ([multi-device](docs/multi-device.md)) |
233
- | `MIKROTIK_DEVICES` | `--devices` | — | Inline JSON of named devices |
234
- | `MIKROTIK_MCP__TRANSPORT` | `--transport` | `stdio` | `stdio` / `streamable-http` / `sse` |
235
- | `MIKROTIK_MCP__PORT` | `--mcp-port` | `8000` | HTTP bind port |
236
- | `MIKROTIK_DASHBOARD__ENABLED` | `--dashboard` | `false` | Real-time observability dashboard ([docs](docs/observability.md)) |
237
+ | Variable | Flag | Default | Purpose |
238
+ | ----------------------------- | ------------------ | ----------- | ---------------------------------------------------------------------------------------------------- |
239
+ | `MIKROTIK_HOST` | `--host` | `127.0.0.1` | RouterOS host |
240
+ | `MIKROTIK_USERNAME` | `--username` | `admin` | SSH user |
241
+ | `MIKROTIK_PORT` | `--port` | `22` | SSH port |
242
+ | `MIKROTIK_PASSWORD` | `--password` | — | SSH password _(or use a key →)_ |
243
+ | `MIKROTIK_KEY_FILENAME` | `--key-filename` | — | SSH private-key file path |
244
+ | `MIKROTIK_PRIVATE_KEY` | `--private-key` | — | Inline private key (PEM) |
245
+ | `MIKROTIK_KEY_PASSPHRASE` | `--key-passphrase` | — | Passphrase for an encrypted key |
246
+ | `MIKROTIK_JUMP_HOST` | `--jump-host` | — | SSH bastion to tunnel through ([jump hosts](docs/multi-device.md#ssh-jump-hosts-bastion--proxyjump)) |
247
+ | `MIKROTIK_CONFIG_FILE` | `--config` | — | JSON file of named devices ([multi-device](docs/multi-device.md)) |
248
+ | `MIKROTIK_DEVICES` | `--devices` | | Inline JSON of named devices |
249
+ | `MIKROTIK_MCP__TRANSPORT` | `--transport` | `stdio` | `stdio` / `streamable-http` / `sse` |
250
+ | `MIKROTIK_MCP__PORT` | `--mcp-port` | `8000` | HTTP bind port |
251
+ | `MIKROTIK_DASHBOARD__ENABLED` | `--dashboard` | `false` | Real-time observability dashboard ([docs](docs/observability.md)) |
237
252
 
238
253
  Full table (incl. HTTP host, allow-lists, timeouts, `MIKROTIK_LOG_LEVEL`):
239
254
  **[docs/configuration.md](docs/configuration.md)**.
package/dist/cli.js CHANGED
@@ -84,7 +84,7 @@ import {
84
84
  toggleAaaEntity,
85
85
  updateAaaEntity,
86
86
  writeBackup
87
- } from "./shared/cli-t84nm0d4.js";
87
+ } from "./shared/cli-gt8xtjx9.js";
88
88
 
89
89
  // src/cli.ts
90
90
  import { existsSync as existsSync2 } from "fs";
@@ -2231,7 +2231,7 @@ function registerPrompts(server) {
2231
2231
  // package.json
2232
2232
  var package_default = {
2233
2233
  name: "@usex/mikrotik-mcp",
2234
- version: "3.32.1",
2234
+ version: "3.33.0",
2235
2235
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
2236
2236
  keywords: [
2237
2237
  "ai",
@@ -2707,16 +2707,20 @@ async function main() {
2707
2707
  warnIfPlaintextPasswordInContainer(Object.values(cfg.devices).some((d) => !!d.password));
2708
2708
  const deviceNames = Object.keys(cfg.devices);
2709
2709
  logger.info(`Starting ${SERVER_NAME} v${VERSION} (transport=${cfg.mcp.transport}, ` + `devices=${deviceNames.length === 1 ? deviceNames[0] : deviceNames.join("/")})`);
2710
- if (cfg.dashboard.enabled) {
2710
+ const startDashboard = async () => {
2711
+ if (!cfg.dashboard.enabled)
2712
+ return;
2711
2713
  try {
2712
2714
  await runDashboard(cfg.dashboard, cfg.mcp.transport);
2713
2715
  } catch (e) {
2714
2716
  logger.error(`Failed to start observability dashboard: ${e instanceof Error ? e.message : String(e)}. ` + "Continuing without it.");
2715
2717
  }
2716
- }
2718
+ };
2717
2719
  if (cfg.mcp.transport === "stdio") {
2718
2720
  await runStdio();
2721
+ await startDashboard();
2719
2722
  } else {
2723
+ await startDashboard();
2720
2724
  await runHttp(cfg.mcp);
2721
2725
  }
2722
2726
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { z } from "zod";
2
- /** Connection details for a single MikroTik device. */
3
2
  declare const DeviceConfigSchema: unknown;
4
3
  type DeviceConfig = z.infer<typeof DeviceConfigSchema>;
5
4
  declare const MikrotikConfigSchema: unknown;
@@ -184,15 +183,47 @@ interface SSHClientOptions {
184
183
  /** Passphrase for an encrypted private key. */
185
184
  keyPassphrase?: string;
186
185
  timeoutMs?: number;
186
+ /**
187
+ * Optional SSH jump host (bastion / ProxyJump). When set, `connect()` reaches
188
+ * `host:port` THROUGH this hop: it opens an SSH session to the hop, asks it to
189
+ * `forwardOut` a TCP channel to the target, then runs the real SSH session
190
+ * over that channel. So the target never needs a port exposed to the network —
191
+ * only the bastion is reachable. The hop is itself an SSH endpoint and may
192
+ * carry its own `jump` for a multi-hop chain (A → B → target).
193
+ */
194
+ jump?: SSHClientOptions;
187
195
  }
188
196
  declare class MikroTikSSHClient {
189
197
  private client;
198
+ /**
199
+ * Open bastion connections, outermost-first, kept alive for the lifetime of
200
+ * the session (the target's transport rides a channel on the last one). Closed
201
+ * in reverse on `disconnect()`.
202
+ */
203
+ private bastions;
190
204
  private readonly opts;
191
205
  /** Human-readable reason the last `connect()` failed, if it did. */
192
206
  lastError?: string;
193
207
  constructor(opts: SSHClientOptions);
194
- /** Establish the SSH connection. Resolves `true` on success, `false` on failure. */
208
+ /**
209
+ * Establish the SSH connection — directly, or through one or more jump hosts
210
+ * when `opts.jump` is set. Resolves `true` on success, `false` on failure
211
+ * (with the reason on `lastError`).
212
+ */
195
213
  connect(): Promise<boolean>;
214
+ /**
215
+ * Open one ssh2 connection. When `sock` is given the connection rides that
216
+ * stream (a forwarded channel from a jump host) instead of dialling the
217
+ * network directly. Resolves the ready `Client`; rejects with a clear reason.
218
+ */
219
+ private openClient;
220
+ /**
221
+ * Ask a connected jump host to open a TCP channel to `host:port` and resolve
222
+ * the resulting stream (used as the next hop's transport `sock`). A RouterOS
223
+ * bastion rejects this unless SSH TCP forwarding is enabled, so the failure
224
+ * names the exact fix.
225
+ */
226
+ private forwardOut;
196
227
  /** Run a single command on a fresh SSH channel and return its decoded output. */
197
228
  run(command: string, opts?: {
198
229
  maxMs?: number;
@@ -211,7 +242,7 @@ declare class MikroTikSSHClient {
211
242
  cols?: number;
212
243
  rows?: number;
213
244
  }): Promise<ClientChannel>;
214
- /** Close the SSH connection. Safe to call multiple times. */
245
+ /** Close the SSH connection (and any jump hosts). Safe to call multiple times. */
215
246
  disconnect(): void;
216
247
  }
217
248
  /**
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  resolveDeviceName,
22
22
  selectToolModules,
23
23
  setConfig
24
- } from "./shared/library-bj9xrcg0.js";
24
+ } from "./shared/library-aa58hkb4.js";
25
25
  // src/server.ts
26
26
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
27
27
  import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
@@ -131,7 +131,7 @@ function registerPrompts(server) {
131
131
  // package.json
132
132
  var package_default = {
133
133
  name: "@usex/mikrotik-mcp",
134
- version: "3.32.1",
134
+ version: "3.33.0",
135
135
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
136
136
  keywords: [
137
137
  "ai",
@@ -4,7 +4,7 @@ import {
4
4
  allToolModules,
5
5
  moduleCatalog,
6
6
  selectToolModules
7
- } from "./cli-t84nm0d4.js";
7
+ } from "./cli-gt8xtjx9.js";
8
8
  export {
9
9
  selectToolModules,
10
10
  moduleCatalog,
@@ -25,6 +25,16 @@ var McpServerSettingsSchema = z.object({
25
25
  toolPageSize: z.coerce.number().int().min(0).default(0),
26
26
  appViews: z.boolean().default(true)
27
27
  });
28
+ var JumpHostSchema = z.object({
29
+ host: z.string(),
30
+ port: z.coerce.number().int().positive().default(22),
31
+ username: z.string().default("admin"),
32
+ password: z.string().optional(),
33
+ keyFilename: z.string().optional(),
34
+ privateKey: z.string().optional(),
35
+ keyPassphrase: z.string().optional(),
36
+ timeoutMs: z.coerce.number().int().positive().optional()
37
+ });
28
38
  var DeviceConfigSchema = z.object({
29
39
  host: z.string().default("127.0.0.1"),
30
40
  username: z.string().default("admin"),
@@ -34,6 +44,8 @@ var DeviceConfigSchema = z.object({
34
44
  privateKey: z.string().optional(),
35
45
  keyPassphrase: z.string().optional(),
36
46
  timeoutMs: z.coerce.number().int().positive().default(1e4),
47
+ jumpVia: z.string().optional(),
48
+ jumpHost: JumpHostSchema.optional(),
37
49
  mac: z.string().optional(),
38
50
  sourceMac: z.string().optional(),
39
51
  macHost: z.string().optional(),
@@ -131,6 +143,15 @@ function getConfigSource() {
131
143
  function loadConfig(argv = process.argv.slice(2)) {
132
144
  const flags = parseFlags(argv);
133
145
  const pick = (flag, ...envNames) => flags[flag] ?? env(...envNames);
146
+ const jumpHostName = pick("jump-host", "MIKROTIK_JUMP_HOST");
147
+ const jumpHost = jumpHostName ? {
148
+ host: jumpHostName,
149
+ port: pick("jump-port", "MIKROTIK_JUMP_PORT"),
150
+ username: pick("jump-username", "MIKROTIK_JUMP_USERNAME"),
151
+ password: pick("jump-password", "MIKROTIK_JUMP_PASSWORD"),
152
+ keyFilename: pick("jump-key-filename", "MIKROTIK_JUMP_KEY_FILENAME"),
153
+ keyPassphrase: pick("jump-key-passphrase", "MIKROTIK_JUMP_KEY_PASSPHRASE")
154
+ } : undefined;
134
155
  const single = {
135
156
  host: pick("host", "MIKROTIK_HOST"),
136
157
  username: pick("username", "MIKROTIK_USERNAME"),
@@ -140,6 +161,7 @@ function loadConfig(argv = process.argv.slice(2)) {
140
161
  privateKey: pick("private-key", "MIKROTIK_PRIVATE_KEY"),
141
162
  keyPassphrase: pick("key-passphrase", "MIKROTIK_KEY_PASSPHRASE"),
142
163
  timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS"),
164
+ jumpHost,
143
165
  mac: pick("mac", "MIKROTIK_MAC"),
144
166
  sourceMac: pick("source-mac", "MIKROTIK_SOURCE_MAC"),
145
167
  macHost: pick("mac-host", "MIKROTIK_MAC_HOST"),
@@ -706,45 +728,72 @@ function decodeOutput(data) {
706
728
 
707
729
  class MikroTikSSHClient {
708
730
  client = null;
731
+ bastions = [];
709
732
  opts;
710
733
  lastError;
711
734
  constructor(opts) {
712
735
  this.opts = { port: 22, timeoutMs: 1e4, ...opts };
713
736
  }
714
- connect() {
737
+ async connect() {
715
738
  this.lastError = undefined;
716
- return new Promise((resolve2) => {
739
+ try {
740
+ const hops = [];
741
+ for (let j = this.opts.jump;j; j = j.jump)
742
+ hops.unshift(j);
743
+ const sequence = [...hops, this.opts];
744
+ let sock;
745
+ for (let i = 0;i < hops.length; i++) {
746
+ const client = await this.openClient(hops[i], sock);
747
+ this.bastions.push(client);
748
+ const next = sequence[i + 1];
749
+ sock = await this.forwardOut(client, next.host, next.port ?? 22);
750
+ }
751
+ this.client = await this.openClient(this.opts, sock);
752
+ return true;
753
+ } catch (e) {
754
+ this.lastError = e instanceof Error ? e.message : String(e);
755
+ logger.error(`Failed to connect to MikroTik: ${this.lastError}`);
756
+ this.disconnect();
757
+ return false;
758
+ }
759
+ }
760
+ openClient(o, sock) {
761
+ return new Promise((resolve2, reject) => {
717
762
  const client = new Client;
718
763
  const cfg = {
719
- host: this.opts.host,
720
- port: this.opts.port,
721
- username: this.opts.username,
722
- readyTimeout: this.opts.timeoutMs
764
+ host: o.host,
765
+ port: o.port ?? 22,
766
+ username: o.username,
767
+ readyTimeout: o.timeoutMs ?? 1e4
723
768
  };
724
- if (this.opts.privateKey) {
725
- cfg.privateKey = this.opts.privateKey;
726
- } else if (this.opts.keyFilename) {
769
+ if (sock)
770
+ cfg.sock = sock;
771
+ if (o.privateKey) {
772
+ cfg.privateKey = o.privateKey;
773
+ } else if (o.keyFilename) {
727
774
  try {
728
- cfg.privateKey = readFileSync2(this.opts.keyFilename);
775
+ cfg.privateKey = readFileSync2(o.keyFilename);
729
776
  } catch (e) {
730
- this.lastError = `could not read key file ${this.opts.keyFilename}: ${e instanceof Error ? e.message : String(e)}`;
731
- logger.error(`Failed to read SSH key file ${this.opts.keyFilename}: ${String(e)}`);
732
- resolve2(false);
777
+ reject(new Error(`could not read key file ${o.keyFilename}: ${e instanceof Error ? e.message : String(e)}`));
733
778
  return;
734
779
  }
735
780
  }
736
- if (cfg.privateKey && this.opts.keyPassphrase)
737
- cfg.passphrase = this.opts.keyPassphrase;
738
- if (this.opts.password)
739
- cfg.password = this.opts.password;
740
- client.on("ready", () => {
741
- this.client = client;
742
- resolve2(true);
743
- }).on("error", (err) => {
744
- this.lastError = err.message;
745
- logger.error(`Failed to connect to MikroTik: ${err.message}`);
746
- resolve2(false);
747
- }).connect(cfg);
781
+ if (cfg.privateKey && o.keyPassphrase)
782
+ cfg.passphrase = o.keyPassphrase;
783
+ if (o.password)
784
+ cfg.password = o.password;
785
+ client.on("ready", () => resolve2(client)).on("error", (err) => reject(err)).connect(cfg);
786
+ });
787
+ }
788
+ forwardOut(via, host, port) {
789
+ return new Promise((resolve2, reject) => {
790
+ via.forwardOut("127.0.0.1", 0, host, port, (err, stream) => {
791
+ if (err) {
792
+ reject(new Error(`jump host could not open a tunnel to ${host}:${port}: ${err.message}. ` + "If the jump router runs RouterOS, enable SSH TCP forwarding on it: " + "/ip ssh set forwarding-enabled=local (or both)."));
793
+ } else {
794
+ resolve2(stream);
795
+ }
796
+ });
748
797
  });
749
798
  }
750
799
  run(command, opts = {}) {
@@ -863,6 +912,12 @@ class MikroTikSSHClient {
863
912
  } catch {}
864
913
  this.client = null;
865
914
  }
915
+ for (const b of this.bastions.reverse()) {
916
+ try {
917
+ b.end();
918
+ } catch {}
919
+ }
920
+ this.bastions = [];
866
921
  }
867
922
  }
868
923
 
@@ -870,6 +925,43 @@ class MikroTikSSHClient {
870
925
  function isMacTelnetDevice(dc) {
871
926
  return Boolean(dc.mac);
872
927
  }
928
+ function sshOptionsOf(dc) {
929
+ return {
930
+ host: dc.host,
931
+ username: dc.username,
932
+ password: dc.password,
933
+ keyFilename: dc.keyFilename,
934
+ privateKey: dc.privateKey,
935
+ keyPassphrase: dc.keyPassphrase,
936
+ port: dc.port,
937
+ timeoutMs: dc.timeoutMs
938
+ };
939
+ }
940
+ function resolveJump(dc, seen = new Set) {
941
+ if (dc.jumpHost) {
942
+ return {
943
+ host: dc.jumpHost.host,
944
+ port: dc.jumpHost.port,
945
+ username: dc.jumpHost.username,
946
+ password: dc.jumpHost.password,
947
+ keyFilename: dc.jumpHost.keyFilename,
948
+ privateKey: dc.jumpHost.privateKey,
949
+ keyPassphrase: dc.jumpHost.keyPassphrase,
950
+ timeoutMs: dc.jumpHost.timeoutMs
951
+ };
952
+ }
953
+ if (!dc.jumpVia)
954
+ return;
955
+ if (seen.has(dc.jumpVia)) {
956
+ throw new Error(`SSH jump-host cycle detected at '${dc.jumpVia}' (a device can't jump through itself).`);
957
+ }
958
+ seen.add(dc.jumpVia);
959
+ const bastion = getDevice(dc.jumpVia);
960
+ if (isMacTelnetDevice(bastion)) {
961
+ throw new Error(`Jump host '${dc.jumpVia}' is a MAC-Telnet device; an SSH bastion must be reachable over SSH.`);
962
+ }
963
+ return { ...sshOptionsOf(bastion), jump: resolveJump(bastion, seen) };
964
+ }
873
965
  function createDeviceClient(dc) {
874
966
  if (isMacTelnetDevice(dc)) {
875
967
  return new MikroTikMacTelnetClient({
@@ -882,16 +974,7 @@ function createDeviceClient(dc) {
882
974
  timeoutMs: dc.timeoutMs
883
975
  });
884
976
  }
885
- return new MikroTikSSHClient({
886
- host: dc.host,
887
- username: dc.username,
888
- password: dc.password,
889
- keyFilename: dc.keyFilename,
890
- privateKey: dc.privateKey,
891
- keyPassphrase: dc.keyPassphrase,
892
- port: dc.port,
893
- timeoutMs: dc.timeoutMs
894
- });
977
+ return new MikroTikSSHClient({ ...sshOptionsOf(dc), jump: resolveJump(dc) });
895
978
  }
896
979
  function describeTransport(dc) {
897
980
  return dc.mac ? `MAC ${dc.mac} (mac-telnet)` : `${dc.host}:${dc.port}`;
@@ -4487,40 +4570,39 @@ var changePlanTools = [
4487
4570
  return "No commands provided to apply.";
4488
4571
  const device = resolveDeviceName(ctx.device);
4489
4572
  if (getDevice(ctx.device).mac) {
4490
- return "Safe Mode (and therefore apply_plan) requires SSH; it is not available on a MAC-Telnet device.";
4573
+ throw new Error("Safe Mode (and therefore apply_plan) requires SSH; it is not available on a MAC-Telnet device.");
4491
4574
  }
4492
4575
  const plan = buildChangePlan(commands);
4493
4576
  const safe = getSafeModeManager(device);
4494
4577
  const enabled2 = await safe.enable();
4495
4578
  if (enabled2.startsWith("Error"))
4496
- return enabled2;
4579
+ throw new Error(enabled2);
4497
4580
  const APPLY_BUDGET_MS = 90000;
4498
4581
  const startedAt = Date.now();
4499
4582
  const overBudget = () => Date.now() - startedAt > APPLY_BUDGET_MS;
4500
- try {
4501
- const before = normalizeExport(await safe.execute("/export terse"));
4502
- const log = [];
4503
- for (const step of plan.steps) {
4504
- if (overBudget()) {
4505
- await safe.rollback();
4506
- return `Aborted after ${Math.round((Date.now() - startedAt) / 1000)}s (time budget exceeded) \u2014 ` + "the plan was ROLLED BACK (nothing committed). Safe Mode is slow/unresponsive on this " + "device; apply the change with the direct write tools instead.";
4507
- }
4508
- const out = await safe.execute(step.command);
4509
- if (looksLikeError(out)) {
4510
- await safe.rollback();
4511
- return `Step ${step.index} failed and the plan was ROLLED BACK (nothing committed):
4512
- ` + ` ${step.command}
4513
- \u2192 ${out.trim()}`;
4583
+ const applyAndDiff = async () => {
4584
+ try {
4585
+ const before = normalizeExport(await safe.execute("/export terse"));
4586
+ const log = [];
4587
+ for (const step of plan.steps) {
4588
+ if (overBudget()) {
4589
+ throw new Error(`aborted after ${Math.round((Date.now() - startedAt) / 1000)}s (time budget exceeded). ` + "Safe Mode is slow/unresponsive on this device; apply the change with the direct write tools instead");
4590
+ }
4591
+ const out = await safe.execute(step.command);
4592
+ if (looksLikeError(out)) {
4593
+ throw new Error(`step ${step.index} failed:
4594
+ ${step.command}
4595
+ \u2192 ${out.trim()}`);
4596
+ }
4597
+ log.push(` \u2713 ${step.command}`);
4514
4598
  }
4515
- log.push(` \u2713 ${step.command}`);
4516
- }
4517
- const after = normalizeExport(await safe.execute("/export terse"));
4518
- const diff = diffLines(before, after, { fromLabel: "before", toLabel: "after" });
4519
- const reachable = !looksLikeError(await safe.execute("/system identity print"));
4520
- const planBody = renderPlan(plan).split(`
4599
+ const after = normalizeExport(await safe.execute("/export terse"));
4600
+ const diff = diffLines(before, after, { fromLabel: "before", toLabel: "after" });
4601
+ const reachable2 = !looksLikeError(await safe.execute("/system identity print"));
4602
+ const planBody = renderPlan(plan).split(`
4521
4603
  `).slice(2).join(`
4522
4604
  `);
4523
- const header = `APPLY PLAN \u2014 ${device}
4605
+ const header2 = `APPLY PLAN \u2014 ${device}
4524
4606
 
4525
4607
  ${planBody}
4526
4608
 
@@ -4530,32 +4612,35 @@ ${log.join(`
4530
4612
 
4531
4613
  ` + `EXACT DIFF (+${diff.summary.added}/-${diff.summary.removed}):
4532
4614
  ${diff.unified || "(no config change)"}`;
4533
- if (!a.confirm) {
4534
- await safe.rollback();
4535
- return `${header}
4615
+ return { header: header2, reachable: reachable2 };
4616
+ } catch (e) {
4617
+ await safe.rollback().catch(() => {});
4618
+ const msg = e instanceof Error ? e.message : String(e);
4619
+ throw new Error(`apply_plan failed and was rolled back (nothing committed): ${msg}`);
4620
+ }
4621
+ };
4622
+ const { header, reachable } = await applyAndDiff();
4623
+ if (!a.confirm) {
4624
+ await safe.rollback();
4625
+ return `${header}
4536
4626
 
4537
4627
  DRY-RUN: all changes rolled back. Re-run with confirm=true to commit.`;
4538
- }
4539
- if (!reachable) {
4540
- await safe.rollback();
4541
- return `${header}
4628
+ }
4629
+ if (!reachable) {
4630
+ await safe.rollback();
4631
+ throw new Error(`${header}
4542
4632
 
4543
- ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`;
4544
- }
4545
- const committed = await safe.commit();
4546
- if (!committed.ok) {
4547
- return `${header}
4633
+ ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`);
4634
+ }
4635
+ const committed = await safe.commit();
4636
+ if (!committed.ok) {
4637
+ throw new Error(`${header}
4548
4638
 
4549
- COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode). ` + `${committed.message}`;
4550
- }
4551
- return `${header}
4639
+ COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode). ${committed.message}`);
4640
+ }
4641
+ return `${header}
4552
4642
 
4553
4643
  COMMITTED: changes are now permanent. ${committed.message}`;
4554
- } catch (e) {
4555
- await safe.rollback();
4556
- const msg = e instanceof Error ? e.message : String(e);
4557
- return `apply_plan failed and was rolled back: ${msg}`;
4558
- }
4559
4644
  }
4560
4645
  })
4561
4646
  ];
@@ -5932,7 +6017,7 @@ var cache = null;
5932
6017
  async function gateway() {
5933
6018
  if (cache)
5934
6019
  return cache;
5935
- const { moduleCatalog } = await import("./cli-ytn5v321.js");
6020
+ const { moduleCatalog } = await import("./cli-g5kqcrt4.js");
5936
6021
  const forIndex = [];
5937
6022
  const byName = new Map;
5938
6023
  for (const mod of moduleCatalog) {
@@ -25,6 +25,16 @@ var McpServerSettingsSchema = z.object({
25
25
  toolPageSize: z.coerce.number().int().min(0).default(0),
26
26
  appViews: z.boolean().default(true)
27
27
  });
28
+ var JumpHostSchema = z.object({
29
+ host: z.string(),
30
+ port: z.coerce.number().int().positive().default(22),
31
+ username: z.string().default("admin"),
32
+ password: z.string().optional(),
33
+ keyFilename: z.string().optional(),
34
+ privateKey: z.string().optional(),
35
+ keyPassphrase: z.string().optional(),
36
+ timeoutMs: z.coerce.number().int().positive().optional()
37
+ });
28
38
  var DeviceConfigSchema = z.object({
29
39
  host: z.string().default("127.0.0.1"),
30
40
  username: z.string().default("admin"),
@@ -34,6 +44,8 @@ var DeviceConfigSchema = z.object({
34
44
  privateKey: z.string().optional(),
35
45
  keyPassphrase: z.string().optional(),
36
46
  timeoutMs: z.coerce.number().int().positive().default(1e4),
47
+ jumpVia: z.string().optional(),
48
+ jumpHost: JumpHostSchema.optional(),
37
49
  mac: z.string().optional(),
38
50
  sourceMac: z.string().optional(),
39
51
  macHost: z.string().optional(),
@@ -128,6 +140,15 @@ var configSource = { path: DEFAULT_CONFIG_FILE, fromFile: false };
128
140
  function loadConfig(argv = process.argv.slice(2)) {
129
141
  const flags = parseFlags(argv);
130
142
  const pick = (flag, ...envNames) => flags[flag] ?? env(...envNames);
143
+ const jumpHostName = pick("jump-host", "MIKROTIK_JUMP_HOST");
144
+ const jumpHost = jumpHostName ? {
145
+ host: jumpHostName,
146
+ port: pick("jump-port", "MIKROTIK_JUMP_PORT"),
147
+ username: pick("jump-username", "MIKROTIK_JUMP_USERNAME"),
148
+ password: pick("jump-password", "MIKROTIK_JUMP_PASSWORD"),
149
+ keyFilename: pick("jump-key-filename", "MIKROTIK_JUMP_KEY_FILENAME"),
150
+ keyPassphrase: pick("jump-key-passphrase", "MIKROTIK_JUMP_KEY_PASSPHRASE")
151
+ } : undefined;
131
152
  const single = {
132
153
  host: pick("host", "MIKROTIK_HOST"),
133
154
  username: pick("username", "MIKROTIK_USERNAME"),
@@ -137,6 +158,7 @@ function loadConfig(argv = process.argv.slice(2)) {
137
158
  privateKey: pick("private-key", "MIKROTIK_PRIVATE_KEY"),
138
159
  keyPassphrase: pick("key-passphrase", "MIKROTIK_KEY_PASSPHRASE"),
139
160
  timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS"),
161
+ jumpHost,
140
162
  mac: pick("mac", "MIKROTIK_MAC"),
141
163
  sourceMac: pick("source-mac", "MIKROTIK_SOURCE_MAC"),
142
164
  macHost: pick("mac-host", "MIKROTIK_MAC_HOST"),
@@ -703,45 +725,72 @@ function decodeOutput(data) {
703
725
 
704
726
  class MikroTikSSHClient {
705
727
  client = null;
728
+ bastions = [];
706
729
  opts;
707
730
  lastError;
708
731
  constructor(opts) {
709
732
  this.opts = { port: 22, timeoutMs: 1e4, ...opts };
710
733
  }
711
- connect() {
734
+ async connect() {
712
735
  this.lastError = undefined;
713
- return new Promise((resolve2) => {
736
+ try {
737
+ const hops = [];
738
+ for (let j = this.opts.jump;j; j = j.jump)
739
+ hops.unshift(j);
740
+ const sequence = [...hops, this.opts];
741
+ let sock;
742
+ for (let i = 0;i < hops.length; i++) {
743
+ const client = await this.openClient(hops[i], sock);
744
+ this.bastions.push(client);
745
+ const next = sequence[i + 1];
746
+ sock = await this.forwardOut(client, next.host, next.port ?? 22);
747
+ }
748
+ this.client = await this.openClient(this.opts, sock);
749
+ return true;
750
+ } catch (e) {
751
+ this.lastError = e instanceof Error ? e.message : String(e);
752
+ logger.error(`Failed to connect to MikroTik: ${this.lastError}`);
753
+ this.disconnect();
754
+ return false;
755
+ }
756
+ }
757
+ openClient(o, sock) {
758
+ return new Promise((resolve2, reject) => {
714
759
  const client = new Client;
715
760
  const cfg = {
716
- host: this.opts.host,
717
- port: this.opts.port,
718
- username: this.opts.username,
719
- readyTimeout: this.opts.timeoutMs
761
+ host: o.host,
762
+ port: o.port ?? 22,
763
+ username: o.username,
764
+ readyTimeout: o.timeoutMs ?? 1e4
720
765
  };
721
- if (this.opts.privateKey) {
722
- cfg.privateKey = this.opts.privateKey;
723
- } else if (this.opts.keyFilename) {
766
+ if (sock)
767
+ cfg.sock = sock;
768
+ if (o.privateKey) {
769
+ cfg.privateKey = o.privateKey;
770
+ } else if (o.keyFilename) {
724
771
  try {
725
- cfg.privateKey = readFileSync2(this.opts.keyFilename);
772
+ cfg.privateKey = readFileSync2(o.keyFilename);
726
773
  } catch (e) {
727
- this.lastError = `could not read key file ${this.opts.keyFilename}: ${e instanceof Error ? e.message : String(e)}`;
728
- logger.error(`Failed to read SSH key file ${this.opts.keyFilename}: ${String(e)}`);
729
- resolve2(false);
774
+ reject(new Error(`could not read key file ${o.keyFilename}: ${e instanceof Error ? e.message : String(e)}`));
730
775
  return;
731
776
  }
732
777
  }
733
- if (cfg.privateKey && this.opts.keyPassphrase)
734
- cfg.passphrase = this.opts.keyPassphrase;
735
- if (this.opts.password)
736
- cfg.password = this.opts.password;
737
- client.on("ready", () => {
738
- this.client = client;
739
- resolve2(true);
740
- }).on("error", (err) => {
741
- this.lastError = err.message;
742
- logger.error(`Failed to connect to MikroTik: ${err.message}`);
743
- resolve2(false);
744
- }).connect(cfg);
778
+ if (cfg.privateKey && o.keyPassphrase)
779
+ cfg.passphrase = o.keyPassphrase;
780
+ if (o.password)
781
+ cfg.password = o.password;
782
+ client.on("ready", () => resolve2(client)).on("error", (err) => reject(err)).connect(cfg);
783
+ });
784
+ }
785
+ forwardOut(via, host, port) {
786
+ return new Promise((resolve2, reject) => {
787
+ via.forwardOut("127.0.0.1", 0, host, port, (err, stream) => {
788
+ if (err) {
789
+ reject(new Error(`jump host could not open a tunnel to ${host}:${port}: ${err.message}. ` + "If the jump router runs RouterOS, enable SSH TCP forwarding on it: " + "/ip ssh set forwarding-enabled=local (or both)."));
790
+ } else {
791
+ resolve2(stream);
792
+ }
793
+ });
745
794
  });
746
795
  }
747
796
  run(command, opts = {}) {
@@ -860,6 +909,12 @@ class MikroTikSSHClient {
860
909
  } catch {}
861
910
  this.client = null;
862
911
  }
912
+ for (const b of this.bastions.reverse()) {
913
+ try {
914
+ b.end();
915
+ } catch {}
916
+ }
917
+ this.bastions = [];
863
918
  }
864
919
  }
865
920
 
@@ -867,6 +922,43 @@ class MikroTikSSHClient {
867
922
  function isMacTelnetDevice(dc) {
868
923
  return Boolean(dc.mac);
869
924
  }
925
+ function sshOptionsOf(dc) {
926
+ return {
927
+ host: dc.host,
928
+ username: dc.username,
929
+ password: dc.password,
930
+ keyFilename: dc.keyFilename,
931
+ privateKey: dc.privateKey,
932
+ keyPassphrase: dc.keyPassphrase,
933
+ port: dc.port,
934
+ timeoutMs: dc.timeoutMs
935
+ };
936
+ }
937
+ function resolveJump(dc, seen = new Set) {
938
+ if (dc.jumpHost) {
939
+ return {
940
+ host: dc.jumpHost.host,
941
+ port: dc.jumpHost.port,
942
+ username: dc.jumpHost.username,
943
+ password: dc.jumpHost.password,
944
+ keyFilename: dc.jumpHost.keyFilename,
945
+ privateKey: dc.jumpHost.privateKey,
946
+ keyPassphrase: dc.jumpHost.keyPassphrase,
947
+ timeoutMs: dc.jumpHost.timeoutMs
948
+ };
949
+ }
950
+ if (!dc.jumpVia)
951
+ return;
952
+ if (seen.has(dc.jumpVia)) {
953
+ throw new Error(`SSH jump-host cycle detected at '${dc.jumpVia}' (a device can't jump through itself).`);
954
+ }
955
+ seen.add(dc.jumpVia);
956
+ const bastion = getDevice(dc.jumpVia);
957
+ if (isMacTelnetDevice(bastion)) {
958
+ throw new Error(`Jump host '${dc.jumpVia}' is a MAC-Telnet device; an SSH bastion must be reachable over SSH.`);
959
+ }
960
+ return { ...sshOptionsOf(bastion), jump: resolveJump(bastion, seen) };
961
+ }
870
962
  function createDeviceClient(dc) {
871
963
  if (isMacTelnetDevice(dc)) {
872
964
  return new MikroTikMacTelnetClient({
@@ -879,16 +971,7 @@ function createDeviceClient(dc) {
879
971
  timeoutMs: dc.timeoutMs
880
972
  });
881
973
  }
882
- return new MikroTikSSHClient({
883
- host: dc.host,
884
- username: dc.username,
885
- password: dc.password,
886
- keyFilename: dc.keyFilename,
887
- privateKey: dc.privateKey,
888
- keyPassphrase: dc.keyPassphrase,
889
- port: dc.port,
890
- timeoutMs: dc.timeoutMs
891
- });
974
+ return new MikroTikSSHClient({ ...sshOptionsOf(dc), jump: resolveJump(dc) });
892
975
  }
893
976
  function connectErrorMessage(name, dc, lastError) {
894
977
  const reason = lastError ? ` \u2014 ${lastError}` : "";
@@ -4402,40 +4485,39 @@ var changePlanTools = [
4402
4485
  return "No commands provided to apply.";
4403
4486
  const device = resolveDeviceName(ctx.device);
4404
4487
  if (getDevice(ctx.device).mac) {
4405
- return "Safe Mode (and therefore apply_plan) requires SSH; it is not available on a MAC-Telnet device.";
4488
+ throw new Error("Safe Mode (and therefore apply_plan) requires SSH; it is not available on a MAC-Telnet device.");
4406
4489
  }
4407
4490
  const plan = buildChangePlan(commands);
4408
4491
  const safe = getSafeModeManager(device);
4409
4492
  const enabled2 = await safe.enable();
4410
4493
  if (enabled2.startsWith("Error"))
4411
- return enabled2;
4494
+ throw new Error(enabled2);
4412
4495
  const APPLY_BUDGET_MS = 90000;
4413
4496
  const startedAt = Date.now();
4414
4497
  const overBudget = () => Date.now() - startedAt > APPLY_BUDGET_MS;
4415
- try {
4416
- const before = normalizeExport(await safe.execute("/export terse"));
4417
- const log = [];
4418
- for (const step of plan.steps) {
4419
- if (overBudget()) {
4420
- await safe.rollback();
4421
- return `Aborted after ${Math.round((Date.now() - startedAt) / 1000)}s (time budget exceeded) \u2014 ` + "the plan was ROLLED BACK (nothing committed). Safe Mode is slow/unresponsive on this " + "device; apply the change with the direct write tools instead.";
4422
- }
4423
- const out = await safe.execute(step.command);
4424
- if (looksLikeError(out)) {
4425
- await safe.rollback();
4426
- return `Step ${step.index} failed and the plan was ROLLED BACK (nothing committed):
4427
- ` + ` ${step.command}
4428
- \u2192 ${out.trim()}`;
4498
+ const applyAndDiff = async () => {
4499
+ try {
4500
+ const before = normalizeExport(await safe.execute("/export terse"));
4501
+ const log = [];
4502
+ for (const step of plan.steps) {
4503
+ if (overBudget()) {
4504
+ throw new Error(`aborted after ${Math.round((Date.now() - startedAt) / 1000)}s (time budget exceeded). ` + "Safe Mode is slow/unresponsive on this device; apply the change with the direct write tools instead");
4505
+ }
4506
+ const out = await safe.execute(step.command);
4507
+ if (looksLikeError(out)) {
4508
+ throw new Error(`step ${step.index} failed:
4509
+ ${step.command}
4510
+ \u2192 ${out.trim()}`);
4511
+ }
4512
+ log.push(` \u2713 ${step.command}`);
4429
4513
  }
4430
- log.push(` \u2713 ${step.command}`);
4431
- }
4432
- const after = normalizeExport(await safe.execute("/export terse"));
4433
- const diff = diffLines(before, after, { fromLabel: "before", toLabel: "after" });
4434
- const reachable = !looksLikeError(await safe.execute("/system identity print"));
4435
- const planBody = renderPlan(plan).split(`
4514
+ const after = normalizeExport(await safe.execute("/export terse"));
4515
+ const diff = diffLines(before, after, { fromLabel: "before", toLabel: "after" });
4516
+ const reachable2 = !looksLikeError(await safe.execute("/system identity print"));
4517
+ const planBody = renderPlan(plan).split(`
4436
4518
  `).slice(2).join(`
4437
4519
  `);
4438
- const header = `APPLY PLAN \u2014 ${device}
4520
+ const header2 = `APPLY PLAN \u2014 ${device}
4439
4521
 
4440
4522
  ${planBody}
4441
4523
 
@@ -4445,32 +4527,35 @@ ${log.join(`
4445
4527
 
4446
4528
  ` + `EXACT DIFF (+${diff.summary.added}/-${diff.summary.removed}):
4447
4529
  ${diff.unified || "(no config change)"}`;
4448
- if (!a.confirm) {
4449
- await safe.rollback();
4450
- return `${header}
4530
+ return { header: header2, reachable: reachable2 };
4531
+ } catch (e) {
4532
+ await safe.rollback().catch(() => {});
4533
+ const msg = e instanceof Error ? e.message : String(e);
4534
+ throw new Error(`apply_plan failed and was rolled back (nothing committed): ${msg}`);
4535
+ }
4536
+ };
4537
+ const { header, reachable } = await applyAndDiff();
4538
+ if (!a.confirm) {
4539
+ await safe.rollback();
4540
+ return `${header}
4451
4541
 
4452
4542
  DRY-RUN: all changes rolled back. Re-run with confirm=true to commit.`;
4453
- }
4454
- if (!reachable) {
4455
- await safe.rollback();
4456
- return `${header}
4543
+ }
4544
+ if (!reachable) {
4545
+ await safe.rollback();
4546
+ throw new Error(`${header}
4457
4547
 
4458
- ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`;
4459
- }
4460
- const committed = await safe.commit();
4461
- if (!committed.ok) {
4462
- return `${header}
4548
+ ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`);
4549
+ }
4550
+ const committed = await safe.commit();
4551
+ if (!committed.ok) {
4552
+ throw new Error(`${header}
4463
4553
 
4464
- COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode). ` + `${committed.message}`;
4465
- }
4466
- return `${header}
4554
+ COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode). ${committed.message}`);
4555
+ }
4556
+ return `${header}
4467
4557
 
4468
4558
  COMMITTED: changes are now permanent. ${committed.message}`;
4469
- } catch (e) {
4470
- await safe.rollback();
4471
- const msg = e instanceof Error ? e.message : String(e);
4472
- return `apply_plan failed and was rolled back: ${msg}`;
4473
- }
4474
4559
  }
4475
4560
  })
4476
4561
  ];
@@ -5847,7 +5932,7 @@ var cache = null;
5847
5932
  async function gateway() {
5848
5933
  if (cache)
5849
5934
  return cache;
5850
- const { moduleCatalog } = await import("./library-gsvkewg5.js");
5935
+ const { moduleCatalog } = await import("./library-c629x3tn.js");
5851
5936
  const forIndex = [];
5852
5937
  const byName = new Map;
5853
5938
  for (const mod of moduleCatalog) {
@@ -4,7 +4,7 @@ import {
4
4
  allToolModules,
5
5
  moduleCatalog,
6
6
  selectToolModules
7
- } from "./library-bj9xrcg0.js";
7
+ } from "./library-aa58hkb4.js";
8
8
  export {
9
9
  selectToolModules,
10
10
  moduleCatalog,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usex/mikrotik-mcp",
3
- "version": "3.32.1",
3
+ "version": "3.33.0",
4
4
  "description": "MCP server for MikroTik RouterOS — 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
5
5
  "keywords": [
6
6
  "ai",
@@ -53,6 +53,46 @@
53
53
  "exclusiveMinimum": 0,
54
54
  "maximum": 9007199254740991
55
55
  },
56
+ "jumpVia": {
57
+ "type": "string"
58
+ },
59
+ "jumpHost": {
60
+ "type": "object",
61
+ "properties": {
62
+ "host": {
63
+ "type": "string"
64
+ },
65
+ "port": {
66
+ "default": 22,
67
+ "type": "integer",
68
+ "exclusiveMinimum": 0,
69
+ "maximum": 9007199254740991
70
+ },
71
+ "username": {
72
+ "default": "admin",
73
+ "type": "string"
74
+ },
75
+ "password": {
76
+ "type": "string"
77
+ },
78
+ "keyFilename": {
79
+ "type": "string"
80
+ },
81
+ "privateKey": {
82
+ "type": "string"
83
+ },
84
+ "keyPassphrase": {
85
+ "type": "string"
86
+ },
87
+ "timeoutMs": {
88
+ "type": "integer",
89
+ "exclusiveMinimum": 0,
90
+ "maximum": 9007199254740991
91
+ }
92
+ },
93
+ "required": ["host", "port", "username"],
94
+ "additionalProperties": false
95
+ },
56
96
  "mac": {
57
97
  "type": "string"
58
98
  },