@usex/mikrotik-mcp 3.20.0 → 3.22.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/dist/cli.js CHANGED
@@ -617,7 +617,7 @@ class MikroTikMacTelnetClient {
617
617
  return false;
618
618
  }
619
619
  }
620
- async run(command) {
620
+ async run(command, _opts = {}) {
621
621
  if (!this.console || !this.console.isReady) {
622
622
  throw new Error("Not connected to MikroTik device (MAC-Telnet)");
623
623
  }
@@ -694,7 +694,7 @@ class MikroTikSSHClient {
694
694
  }).connect(cfg);
695
695
  });
696
696
  }
697
- run(command) {
697
+ run(command, opts = {}) {
698
698
  if (!this.client) {
699
699
  return Promise.reject(new Error("Not connected to MikroTik device"));
700
700
  }
@@ -707,11 +707,30 @@ class MikroTikSSHClient {
707
707
  }
708
708
  const stdout = [];
709
709
  const stderrBuf = [];
710
- stream.on("close", () => {
710
+ let settled = false;
711
+ let timer;
712
+ const finish = () => {
713
+ if (settled)
714
+ return;
715
+ settled = true;
716
+ if (timer)
717
+ clearTimeout(timer);
711
718
  const out = decodeOutput(Buffer.concat(stdout));
712
719
  const error = decodeOutput(Buffer.concat(stderrBuf));
713
720
  resolve2(error && !out ? error : out);
714
- }).on("data", (d) => stdout.push(d)).stderr.on("data", (d) => stderrBuf.push(d));
721
+ };
722
+ if (opts.maxMs && opts.maxMs > 0) {
723
+ timer = setTimeout(() => {
724
+ try {
725
+ stream.signal("INT");
726
+ } catch {}
727
+ try {
728
+ stream.close();
729
+ } catch {}
730
+ finish();
731
+ }, opts.maxMs);
732
+ }
733
+ stream.on("close", finish).on("data", (d) => stdout.push(d)).stderr.on("data", (d) => stderrBuf.push(d));
715
734
  });
716
735
  });
717
736
  }
@@ -875,31 +894,39 @@ class SafeModeManager {
875
894
  }
876
895
  commit() {
877
896
  return this.lock(async () => {
878
- if (!this.active || !this.channel)
879
- return "Safe mode is not active. Nothing to commit.";
880
- this.channel.write(`
881
- `);
882
- const probe = await this.readSettledPrompt();
883
- const before = classifyPrompt(probe);
897
+ if (!this.active || !this.channel) {
898
+ return { ok: true, message: "Safe mode is not active. Nothing to commit." };
899
+ }
900
+ const before = await this.probeMode();
884
901
  if (before === "released") {
885
902
  this.cleanup();
886
- return "Safe mode already exited \u2014 your changes are committed. Safe mode DISABLED.";
903
+ return {
904
+ ok: true,
905
+ message: "Safe mode already exited \u2014 your changes are committed. Safe mode DISABLED."
906
+ };
887
907
  }
888
908
  if (before === "unknown") {
889
- return "Could not read a prompt to determine Safe Mode state. The session is left open so " + "nothing is reverted \u2014 call get_safe_mode_status, retry commit_safe_mode, or " + `rollback_safe_mode. Last output: ${probe.slice(-160)}`;
909
+ return {
910
+ ok: false,
911
+ message: "Could not read a prompt to determine Safe Mode state. The session is left open so " + "nothing is reverted \u2014 call get_safe_mode_status, retry commit_safe_mode, or rollback_safe_mode."
912
+ };
890
913
  }
891
914
  this.channel.write(CTRL_X);
892
- this.channel.write(`
893
- `);
894
- const after = await this.readSettledPrompt();
895
- switch (classifyPrompt(after)) {
915
+ const after = await this.probeMode();
916
+ switch (after) {
896
917
  case "released":
897
918
  this.cleanup();
898
- return "Changes committed successfully. Safe mode DISABLED.";
919
+ return { ok: true, message: "Changes committed successfully. Safe mode DISABLED." };
899
920
  case "safe":
900
- return "Commit not completed \u2014 the device is still in Safe Mode. Your changes remain held " + "in memory (not reverted); call commit_safe_mode again to retry, or rollback_safe_mode " + "to discard them.";
921
+ return {
922
+ ok: false,
923
+ message: "Commit NOT completed \u2014 the device is still in Safe Mode (the Ctrl+X commit did not " + "take). Your changes remain held in memory and are NOT yet saved; call commit_safe_mode " + "again to retry, or rollback_safe_mode to discard them. If retries keep failing, this " + "RouterOS build may not accept an interactive Safe Mode commit over SSH \u2014 apply the " + "change without Safe Mode instead."
924
+ };
901
925
  default:
902
- return "Commit status unclear \u2014 no prompt seen after exiting Safe Mode. The session is left " + "open so nothing is reverted; verify with get_safe_mode_status or retry commit_safe_mode. " + `Last output: ${after.slice(-160)}`;
926
+ return {
927
+ ok: false,
928
+ message: "Commit status unclear \u2014 no prompt seen after the commit. The session is left open so " + "nothing is reverted; verify with get_safe_mode_status or retry commit_safe_mode."
929
+ };
903
930
  }
904
931
  });
905
932
  }
@@ -936,32 +963,17 @@ class SafeModeManager {
936
963
  channel.on("data", onData);
937
964
  });
938
965
  }
939
- readSettledPrompt(quietMs = 450, maxMs = 8000) {
940
- const channel = this.channel;
941
- if (!channel)
942
- return Promise.resolve("");
943
- return new Promise((resolve2) => {
944
- let buf = "";
945
- let quiet;
946
- let hard;
947
- function done() {
948
- clearTimeout(hard);
949
- if (quiet)
950
- clearTimeout(quiet);
951
- channel.removeListener("data", onData);
952
- resolve2(stripAnsi(buf));
953
- }
954
- function onData(chunk) {
955
- buf += decodeOutput(chunk);
956
- if (PROMPT_RE.test(lastNonEmptyLine(stripAnsi(buf)))) {
957
- if (quiet)
958
- clearTimeout(quiet);
959
- quiet = setTimeout(done, quietMs);
960
- }
961
- }
962
- hard = setTimeout(done, maxMs);
963
- channel.on("data", onData);
966
+ async probeMode() {
967
+ if (!this.channel)
968
+ return "unknown";
969
+ const token = "__MCP_SAFEMODE_PROBE__";
970
+ this.channel.write(`:put "${token}"
971
+ `);
972
+ const out = await this.readUntilPrompt(8000, (cleaned) => {
973
+ const i = cleaned.lastIndexOf(token);
974
+ return i >= 0 && PROMPT_RE.test(cleaned.slice(i));
964
975
  });
976
+ return classifyPrompt(out);
965
977
  }
966
978
  extractOutput(raw, command) {
967
979
  const text = raw.replace(/\r\n/g, `
@@ -1010,7 +1022,7 @@ function getSafeModeManager(deviceName) {
1010
1022
  }
1011
1023
 
1012
1024
  // src/core/connector.ts
1013
- async function runOnce(command, deviceName) {
1025
+ async function runOnce(command, deviceName, opts) {
1014
1026
  const name = resolveDeviceName(deviceName);
1015
1027
  const dc = getDevice(deviceName);
1016
1028
  const client = createDeviceClient(dc);
@@ -1018,12 +1030,12 @@ async function runOnce(command, deviceName) {
1018
1030
  if (!await client.connect()) {
1019
1031
  throw new Error(connectErrorMessage(name, dc, client.lastError));
1020
1032
  }
1021
- return await client.run(command);
1033
+ return await client.run(command, opts);
1022
1034
  } finally {
1023
1035
  client.disconnect();
1024
1036
  }
1025
1037
  }
1026
- async function executeMikrotikCommand(command, ctx) {
1038
+ async function executeMikrotikCommand(command, ctx, opts) {
1027
1039
  const deviceName = resolveDeviceName(ctx.device);
1028
1040
  const safe = getSafeModeManager(deviceName);
1029
1041
  if (safe.isActive) {
@@ -1031,7 +1043,7 @@ async function executeMikrotikCommand(command, ctx) {
1031
1043
  return safe.execute(command);
1032
1044
  }
1033
1045
  ctx.info(`[${deviceName}] Executing MikroTik command: ${command}`);
1034
- return runOnce(command, ctx.device);
1046
+ return runOnce(command, ctx.device, opts);
1035
1047
  }
1036
1048
 
1037
1049
  // src/core/registry.ts
@@ -1124,6 +1136,16 @@ function isEmpty(result) {
1124
1136
  const t = result.trim();
1125
1137
  return t === "" || t === "no such item" || t === "no such item (4)";
1126
1138
  }
1139
+ function flattenLiveOutput(text) {
1140
+ return text.split(`
1141
+ `).map((line) => {
1142
+ const segs = line.split("\r").filter((s) => s.trim() !== "");
1143
+ return segs.length ? segs[segs.length - 1] : "";
1144
+ }).join(`
1145
+ `).replace(/\n{3,}/g, `
1146
+
1147
+ `).trim();
1148
+ }
1127
1149
  function extractCreatedId(output) {
1128
1150
  const star = output.match(/\*[0-9A-Fa-f]+/);
1129
1151
  if (star)
@@ -2603,11 +2625,19 @@ async function restoreLocalBackup(device, name, confirm) {
2603
2625
  return fail("device stopped responding after applying \u2014 rolled back to avoid a lock-out", applied);
2604
2626
  }
2605
2627
  const committed = await safe.commit();
2628
+ if (!committed.ok) {
2629
+ return {
2630
+ ok: false,
2631
+ applied,
2632
+ committed: false,
2633
+ message: `applied ${applied} command(s) but COMMIT FAILED \u2014 not saved: ${committed.message}`
2634
+ };
2635
+ }
2606
2636
  return {
2607
2637
  ok: true,
2608
2638
  applied,
2609
2639
  committed: true,
2610
- message: `committed ${applied} command(s). ${committed}`
2640
+ message: `committed ${applied} command(s). ${committed.message}`
2611
2641
  };
2612
2642
  } catch (e) {
2613
2643
  await safe.rollback();
@@ -3841,9 +3871,14 @@ DRY-RUN: all changes rolled back. Re-run with confirm=true to commit.`;
3841
3871
  ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`;
3842
3872
  }
3843
3873
  const committed = await safe.commit();
3874
+ if (!committed.ok) {
3875
+ return `${header}
3876
+
3877
+ COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode). ` + `${committed.message}`;
3878
+ }
3844
3879
  return `${header}
3845
3880
 
3846
- COMMITTED: changes are now permanent. ${committed}`;
3881
+ COMMITTED: changes are now permanent. ${committed.message}`;
3847
3882
  } catch (e) {
3848
3883
  await safe.rollback();
3849
3884
  const msg = e instanceof Error ? e.message : String(e);
@@ -5906,8 +5941,12 @@ ${useSafe ? "Safe Mode rolled back \u2014 no changes kept." : "Partial rules rem
5906
5941
  }
5907
5942
  done.push(cmd);
5908
5943
  }
5909
- if (useSafe)
5910
- await mgr.commit();
5944
+ if (useSafe) {
5945
+ const c = await mgr.commit();
5946
+ if (!c.ok) {
5947
+ return `Applied ${done.length} rule(s) but Safe Mode COMMIT FAILED \u2014 changes are NOT saved ` + `and will revert: ${c.message}`;
5948
+ }
5949
+ }
5911
5950
  return `Security Shield applied \u2014 ${done.length} rule(s) across ${groups.length} protection(s)${useSafe ? " (committed via Safe Mode)" : ""}. Audit with audit_firewall_hardening; undo with remove_firewall_hardening.`;
5912
5951
  }
5913
5952
  }),
@@ -11406,7 +11445,7 @@ var networkToolTools = [
11406
11445
  async handler(a, ctx) {
11407
11446
  ctx.info(`Pinging ${a.address} (count=${a.count})`);
11408
11447
  const cmd = new Cmd(`/ping ${a.address}`).set("count", a.count).opt("interface", a.interface).opt("src-address", a.src_address).opt("size", a.size).build();
11409
- const result = await executeMikrotikCommand(cmd, ctx);
11448
+ const result = flattenLiveOutput(await executeMikrotikCommand(cmd, ctx, { maxMs: a.count * 1500 + 6000 }));
11410
11449
  if (looksLikeError(result))
11411
11450
  return `Failed to ping ${a.address}: ${result}`;
11412
11451
  return isEmpty(result) ? `No response from ${a.address}.` : `PING ${a.address}:
@@ -11427,7 +11466,7 @@ ${result}`;
11427
11466
  async handler(a, ctx) {
11428
11467
  ctx.info(`Tracerouting ${a.address} (count=${a.count})`);
11429
11468
  const cmd = new Cmd(`/tool traceroute ${a.address}`).set("count", a.count).flag("use-dns", a.use_dns).build();
11430
- const result = await executeMikrotikCommand(cmd, ctx);
11469
+ const result = flattenLiveOutput(await executeMikrotikCommand(cmd, ctx, { maxMs: 30000 + a.count * 3000 }));
11431
11470
  if (looksLikeError(result))
11432
11471
  return `Failed to traceroute ${a.address}: ${result}`;
11433
11472
  return isEmpty(result) ? `No route information for ${a.address}.` : `TRACEROUTE ${a.address}:
@@ -11451,7 +11490,7 @@ ${result}`;
11451
11490
  async handler(a, ctx) {
11452
11491
  ctx.info(`Bandwidth test to ${a.address} (duration=${a.duration}s, direction=${a.direction})`);
11453
11492
  const cmd = new Cmd(`/tool bandwidth-test ${a.address}`).set("duration", `${a.duration}s`).set("direction", a.direction).set("protocol", a.protocol).opt("user", a.user).opt("password", a.password).build();
11454
- const result = await executeMikrotikCommand(cmd, ctx);
11493
+ const result = flattenLiveOutput(await executeMikrotikCommand(cmd, ctx, { maxMs: a.duration * 1000 + 12000 }));
11455
11494
  if (looksLikeError(result))
11456
11495
  return `Failed to run bandwidth test to ${a.address}: ${result}`;
11457
11496
  return isEmpty(result) ? `No bandwidth test results for ${a.address}.` : `BANDWIDTH TEST:
@@ -12103,7 +12142,7 @@ var floodPingTools = [
12103
12142
  async handler(a, ctx) {
12104
12143
  ctx.info(`Flood-pinging ${a.address} (count=${a.count})`);
12105
12144
  const cmd = new Cmd(`/tool flood-ping ${a.address}`).set("count", a.count).opt("size", a.size).opt("interface", a.interface).opt("src-address", a.src_address).build();
12106
- const result = await executeMikrotikCommand(cmd, ctx);
12145
+ const result = flattenLiveOutput(await executeMikrotikCommand(cmd, ctx, { maxMs: 30000 }));
12107
12146
  if (looksLikeError(result))
12108
12147
  return `Failed to flood-ping ${a.address}: ${result}`;
12109
12148
  return isEmpty(result) ? `No response from ${a.address}.` : `FLOOD PING ${a.address}:
@@ -12767,7 +12806,7 @@ var speedTestTools = [
12767
12806
  async handler(a, ctx) {
12768
12807
  ctx.info(`Speed test to ${a.address} (duration=${a.duration}s, direction=${a.direction})`);
12769
12808
  const cmd = new Cmd(`/tool speed-test address=${a.address}`).set("duration", `${a.duration}s`).set("direction", a.direction).opt("tcp-connection-count", a.tcp_connection_count).opt("user", a.user).opt("password", a.password).build();
12770
- const result = await executeMikrotikCommand(cmd, ctx);
12809
+ const result = flattenLiveOutput(await executeMikrotikCommand(cmd, ctx, { maxMs: a.duration * 1000 + 15000 }));
12771
12810
  if (looksLikeError(result))
12772
12811
  return `Failed to run speed test to ${a.address}: ${result}`;
12773
12812
  return isEmpty(result) ? `No speed-test results for ${a.address}.` : `SPEED TEST:
@@ -17554,7 +17593,7 @@ var safeModeTools = [
17554
17593
  async handler(_a, ctx) {
17555
17594
  const device = resolveDeviceName(ctx.device);
17556
17595
  ctx.info(`[${device}] Committing safe mode changes`);
17557
- return getSafeModeManager(device).commit();
17596
+ return (await getSafeModeManager(device).commit()).message;
17558
17597
  }
17559
17598
  }),
17560
17599
  defineTool({
@@ -23922,7 +23961,7 @@ function registerPrompts(server) {
23922
23961
  // package.json
23923
23962
  var package_default = {
23924
23963
  name: "@usex/mikrotik-mcp",
23925
- version: "3.20.0",
23964
+ version: "3.22.0",
23926
23965
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
23927
23966
  keywords: [
23928
23967
  "ai",
package/dist/index.d.ts CHANGED
@@ -26,8 +26,12 @@ type SendLog = (level: "info" | "error", message: string) => void;
26
26
  *
27
27
  * @param command Fully-formed RouterOS CLI command (e.g. `/ip address print`).
28
28
  * @param ctx Per-call context carrying the target device.
29
+ * @param opts `maxMs` caps the one-shot read for interactive/streaming
30
+ * commands (ping, bandwidth-test) so they can't hang the tool.
29
31
  */
30
- declare function executeMikrotikCommand(command: string, ctx: ToolContext): Promise<string>;
32
+ declare function executeMikrotikCommand(command: string, ctx: ToolContext, opts?: {
33
+ maxMs?: number;
34
+ }): Promise<string>;
31
35
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
32
36
  import { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
33
37
  import { ZodRawShape } from "zod";
@@ -147,7 +151,9 @@ declare class MikroTikSSHClient {
147
151
  /** Establish the SSH connection. Resolves `true` on success, `false` on failure. */
148
152
  connect(): Promise<boolean>;
149
153
  /** Run a single command on a fresh SSH channel and return its decoded output. */
150
- run(command: string): Promise<string>;
154
+ run(command: string, opts?: {
155
+ maxMs?: number;
156
+ }): Promise<string>;
151
157
  /** Open a persistent interactive shell channel (used by Safe Mode). */
152
158
  shell(opts?: {
153
159
  term?: string;
@@ -157,6 +163,16 @@ declare class MikroTikSSHClient {
157
163
  /** Close the SSH connection. Safe to call multiple times. */
158
164
  disconnect(): void;
159
165
  }
166
+ /**
167
+ * Outcome of {@link SafeModeManager.commit}. `ok` is true ONLY when Safe Mode is
168
+ * confirmed exited (changes persisted) — callers must check it before telling
169
+ * the user anything was committed, so a failed commit can never masquerade as a
170
+ * success that then silently reverts.
171
+ */
172
+ interface CommitResult {
173
+ ok: boolean;
174
+ message: string;
175
+ }
160
176
  declare class SafeModeManager {
161
177
  private readonly deviceName;
162
178
  private ssh;
@@ -173,8 +189,12 @@ declare class SafeModeManager {
173
189
  enable(): Promise<string>;
174
190
  /** Execute a command through the safe-mode persistent shell session. */
175
191
  execute(command: string): Promise<string>;
176
- /** Send Ctrl+X again to exit Safe Mode and persist all changes. */
177
- commit(): Promise<string>;
192
+ /**
193
+ * Send Ctrl+X again to exit Safe Mode and persist all changes. Returns a
194
+ * structured result so callers NEVER report "committed" on a failed commit —
195
+ * `ok` is true only when Safe Mode is confirmed exited (changes saved).
196
+ */
197
+ commit(): Promise<CommitResult>;
178
198
  /** Close the session to trigger MikroTik's automatic safe-mode revert. */
179
199
  rollback(): Promise<string>;
180
200
  status(): string;
@@ -186,13 +206,14 @@ declare class SafeModeManager {
186
206
  */
187
207
  private readUntilPrompt;
188
208
  /**
189
- * Read until the output SETTLES: once any prompt is visible, wait for a quiet
190
- * gap (`quietMs` with no new bytes) before resolving, or give up at `maxMs`.
191
- * Used by commit, where Ctrl+X + Enter can emit a transient `<SAFE>` redraw
192
- * followed by the real post-commit prompt settling on the LAST prompt after
193
- * a quiet period is what makes mode detection reliable.
209
+ * Determine the shell's CURRENT mode definitively by round-tripping a sentinel
210
+ * command (`:put "<token>"`) and classifying the prompt that follows ITS
211
+ * output. A real command not a bare Enter forces RouterOS to fully process
212
+ * any pending Ctrl+X and render a prompt that reflects the true mode, so we
213
+ * never settle on a transient `<SAFE>`→normal redraw and report a commit that
214
+ * did not actually take. Returns `safe`, `released`, or `unknown` (no prompt).
194
215
  */
195
- private readSettledPrompt;
216
+ private probeMode;
196
217
  private extractOutput;
197
218
  private cleanup;
198
219
  }
package/dist/index.js CHANGED
@@ -609,7 +609,7 @@ class MikroTikMacTelnetClient {
609
609
  return false;
610
610
  }
611
611
  }
612
- async run(command) {
612
+ async run(command, _opts = {}) {
613
613
  if (!this.console || !this.console.isReady) {
614
614
  throw new Error("Not connected to MikroTik device (MAC-Telnet)");
615
615
  }
@@ -686,7 +686,7 @@ class MikroTikSSHClient {
686
686
  }).connect(cfg);
687
687
  });
688
688
  }
689
- run(command) {
689
+ run(command, opts = {}) {
690
690
  if (!this.client) {
691
691
  return Promise.reject(new Error("Not connected to MikroTik device"));
692
692
  }
@@ -699,11 +699,30 @@ class MikroTikSSHClient {
699
699
  }
700
700
  const stdout = [];
701
701
  const stderrBuf = [];
702
- stream.on("close", () => {
702
+ let settled = false;
703
+ let timer;
704
+ const finish = () => {
705
+ if (settled)
706
+ return;
707
+ settled = true;
708
+ if (timer)
709
+ clearTimeout(timer);
703
710
  const out = decodeOutput(Buffer.concat(stdout));
704
711
  const error = decodeOutput(Buffer.concat(stderrBuf));
705
712
  resolve2(error && !out ? error : out);
706
- }).on("data", (d) => stdout.push(d)).stderr.on("data", (d) => stderrBuf.push(d));
713
+ };
714
+ if (opts.maxMs && opts.maxMs > 0) {
715
+ timer = setTimeout(() => {
716
+ try {
717
+ stream.signal("INT");
718
+ } catch {}
719
+ try {
720
+ stream.close();
721
+ } catch {}
722
+ finish();
723
+ }, opts.maxMs);
724
+ }
725
+ stream.on("close", finish).on("data", (d) => stdout.push(d)).stderr.on("data", (d) => stderrBuf.push(d));
707
726
  });
708
727
  });
709
728
  }
@@ -861,31 +880,39 @@ class SafeModeManager {
861
880
  }
862
881
  commit() {
863
882
  return this.lock(async () => {
864
- if (!this.active || !this.channel)
865
- return "Safe mode is not active. Nothing to commit.";
866
- this.channel.write(`
867
- `);
868
- const probe = await this.readSettledPrompt();
869
- const before = classifyPrompt(probe);
883
+ if (!this.active || !this.channel) {
884
+ return { ok: true, message: "Safe mode is not active. Nothing to commit." };
885
+ }
886
+ const before = await this.probeMode();
870
887
  if (before === "released") {
871
888
  this.cleanup();
872
- return "Safe mode already exited \u2014 your changes are committed. Safe mode DISABLED.";
889
+ return {
890
+ ok: true,
891
+ message: "Safe mode already exited \u2014 your changes are committed. Safe mode DISABLED."
892
+ };
873
893
  }
874
894
  if (before === "unknown") {
875
- return "Could not read a prompt to determine Safe Mode state. The session is left open so " + "nothing is reverted \u2014 call get_safe_mode_status, retry commit_safe_mode, or " + `rollback_safe_mode. Last output: ${probe.slice(-160)}`;
895
+ return {
896
+ ok: false,
897
+ message: "Could not read a prompt to determine Safe Mode state. The session is left open so " + "nothing is reverted \u2014 call get_safe_mode_status, retry commit_safe_mode, or rollback_safe_mode."
898
+ };
876
899
  }
877
900
  this.channel.write(CTRL_X);
878
- this.channel.write(`
879
- `);
880
- const after = await this.readSettledPrompt();
881
- switch (classifyPrompt(after)) {
901
+ const after = await this.probeMode();
902
+ switch (after) {
882
903
  case "released":
883
904
  this.cleanup();
884
- return "Changes committed successfully. Safe mode DISABLED.";
905
+ return { ok: true, message: "Changes committed successfully. Safe mode DISABLED." };
885
906
  case "safe":
886
- return "Commit not completed \u2014 the device is still in Safe Mode. Your changes remain held " + "in memory (not reverted); call commit_safe_mode again to retry, or rollback_safe_mode " + "to discard them.";
907
+ return {
908
+ ok: false,
909
+ message: "Commit NOT completed \u2014 the device is still in Safe Mode (the Ctrl+X commit did not " + "take). Your changes remain held in memory and are NOT yet saved; call commit_safe_mode " + "again to retry, or rollback_safe_mode to discard them. If retries keep failing, this " + "RouterOS build may not accept an interactive Safe Mode commit over SSH \u2014 apply the " + "change without Safe Mode instead."
910
+ };
887
911
  default:
888
- return "Commit status unclear \u2014 no prompt seen after exiting Safe Mode. The session is left " + "open so nothing is reverted; verify with get_safe_mode_status or retry commit_safe_mode. " + `Last output: ${after.slice(-160)}`;
912
+ return {
913
+ ok: false,
914
+ message: "Commit status unclear \u2014 no prompt seen after the commit. The session is left open so " + "nothing is reverted; verify with get_safe_mode_status or retry commit_safe_mode."
915
+ };
889
916
  }
890
917
  });
891
918
  }
@@ -922,32 +949,17 @@ class SafeModeManager {
922
949
  channel.on("data", onData);
923
950
  });
924
951
  }
925
- readSettledPrompt(quietMs = 450, maxMs = 8000) {
926
- const channel = this.channel;
927
- if (!channel)
928
- return Promise.resolve("");
929
- return new Promise((resolve2) => {
930
- let buf = "";
931
- let quiet;
932
- let hard;
933
- function done() {
934
- clearTimeout(hard);
935
- if (quiet)
936
- clearTimeout(quiet);
937
- channel.removeListener("data", onData);
938
- resolve2(stripAnsi(buf));
939
- }
940
- function onData(chunk) {
941
- buf += decodeOutput(chunk);
942
- if (PROMPT_RE.test(lastNonEmptyLine(stripAnsi(buf)))) {
943
- if (quiet)
944
- clearTimeout(quiet);
945
- quiet = setTimeout(done, quietMs);
946
- }
947
- }
948
- hard = setTimeout(done, maxMs);
949
- channel.on("data", onData);
952
+ async probeMode() {
953
+ if (!this.channel)
954
+ return "unknown";
955
+ const token = "__MCP_SAFEMODE_PROBE__";
956
+ this.channel.write(`:put "${token}"
957
+ `);
958
+ const out = await this.readUntilPrompt(8000, (cleaned) => {
959
+ const i = cleaned.lastIndexOf(token);
960
+ return i >= 0 && PROMPT_RE.test(cleaned.slice(i));
950
961
  });
962
+ return classifyPrompt(out);
951
963
  }
952
964
  extractOutput(raw, command) {
953
965
  const text = raw.replace(/\r\n/g, `
@@ -996,7 +1008,7 @@ function getSafeModeManager(deviceName) {
996
1008
  }
997
1009
 
998
1010
  // src/core/connector.ts
999
- async function runOnce(command, deviceName) {
1011
+ async function runOnce(command, deviceName, opts) {
1000
1012
  const name = resolveDeviceName(deviceName);
1001
1013
  const dc = getDevice(deviceName);
1002
1014
  const client = createDeviceClient(dc);
@@ -1004,12 +1016,12 @@ async function runOnce(command, deviceName) {
1004
1016
  if (!await client.connect()) {
1005
1017
  throw new Error(connectErrorMessage(name, dc, client.lastError));
1006
1018
  }
1007
- return await client.run(command);
1019
+ return await client.run(command, opts);
1008
1020
  } finally {
1009
1021
  client.disconnect();
1010
1022
  }
1011
1023
  }
1012
- async function executeMikrotikCommand(command, ctx) {
1024
+ async function executeMikrotikCommand(command, ctx, opts) {
1013
1025
  const deviceName = resolveDeviceName(ctx.device);
1014
1026
  const safe = getSafeModeManager(deviceName);
1015
1027
  if (safe.isActive) {
@@ -1017,7 +1029,7 @@ async function executeMikrotikCommand(command, ctx) {
1017
1029
  return safe.execute(command);
1018
1030
  }
1019
1031
  ctx.info(`[${deviceName}] Executing MikroTik command: ${command}`);
1020
- return runOnce(command, ctx.device);
1032
+ return runOnce(command, ctx.device, opts);
1021
1033
  }
1022
1034
  // src/core/registry.ts
1023
1035
  import { z as z2 } from "zod";
@@ -1109,6 +1121,16 @@ function isEmpty(result) {
1109
1121
  const t = result.trim();
1110
1122
  return t === "" || t === "no such item" || t === "no such item (4)";
1111
1123
  }
1124
+ function flattenLiveOutput(text) {
1125
+ return text.split(`
1126
+ `).map((line) => {
1127
+ const segs = line.split("\r").filter((s) => s.trim() !== "");
1128
+ return segs.length ? segs[segs.length - 1] : "";
1129
+ }).join(`
1130
+ `).replace(/\n{3,}/g, `
1131
+
1132
+ `).trim();
1133
+ }
1112
1134
  function extractCreatedId(output) {
1113
1135
  const star = output.match(/\*[0-9A-Fa-f]+/);
1114
1136
  if (star)
@@ -2615,11 +2637,19 @@ async function restoreLocalBackup(device, name, confirm) {
2615
2637
  return fail("device stopped responding after applying \u2014 rolled back to avoid a lock-out", applied);
2616
2638
  }
2617
2639
  const committed = await safe.commit();
2640
+ if (!committed.ok) {
2641
+ return {
2642
+ ok: false,
2643
+ applied,
2644
+ committed: false,
2645
+ message: `applied ${applied} command(s) but COMMIT FAILED \u2014 not saved: ${committed.message}`
2646
+ };
2647
+ }
2618
2648
  return {
2619
2649
  ok: true,
2620
2650
  applied,
2621
2651
  committed: true,
2622
- message: `committed ${applied} command(s). ${committed}`
2652
+ message: `committed ${applied} command(s). ${committed.message}`
2623
2653
  };
2624
2654
  } catch (e) {
2625
2655
  await safe.rollback();
@@ -3853,9 +3883,14 @@ DRY-RUN: all changes rolled back. Re-run with confirm=true to commit.`;
3853
3883
  ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`;
3854
3884
  }
3855
3885
  const committed = await safe.commit();
3886
+ if (!committed.ok) {
3887
+ return `${header}
3888
+
3889
+ COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode). ` + `${committed.message}`;
3890
+ }
3856
3891
  return `${header}
3857
3892
 
3858
- COMMITTED: changes are now permanent. ${committed}`;
3893
+ COMMITTED: changes are now permanent. ${committed.message}`;
3859
3894
  } catch (e) {
3860
3895
  await safe.rollback();
3861
3896
  const msg = e instanceof Error ? e.message : String(e);
@@ -5918,8 +5953,12 @@ ${useSafe ? "Safe Mode rolled back \u2014 no changes kept." : "Partial rules rem
5918
5953
  }
5919
5954
  done.push(cmd);
5920
5955
  }
5921
- if (useSafe)
5922
- await mgr.commit();
5956
+ if (useSafe) {
5957
+ const c = await mgr.commit();
5958
+ if (!c.ok) {
5959
+ return `Applied ${done.length} rule(s) but Safe Mode COMMIT FAILED \u2014 changes are NOT saved ` + `and will revert: ${c.message}`;
5960
+ }
5961
+ }
5923
5962
  return `Security Shield applied \u2014 ${done.length} rule(s) across ${groups.length} protection(s)${useSafe ? " (committed via Safe Mode)" : ""}. Audit with audit_firewall_hardening; undo with remove_firewall_hardening.`;
5924
5963
  }
5925
5964
  }),
@@ -11418,7 +11457,7 @@ var networkToolTools = [
11418
11457
  async handler(a, ctx) {
11419
11458
  ctx.info(`Pinging ${a.address} (count=${a.count})`);
11420
11459
  const cmd = new Cmd(`/ping ${a.address}`).set("count", a.count).opt("interface", a.interface).opt("src-address", a.src_address).opt("size", a.size).build();
11421
- const result = await executeMikrotikCommand(cmd, ctx);
11460
+ const result = flattenLiveOutput(await executeMikrotikCommand(cmd, ctx, { maxMs: a.count * 1500 + 6000 }));
11422
11461
  if (looksLikeError(result))
11423
11462
  return `Failed to ping ${a.address}: ${result}`;
11424
11463
  return isEmpty(result) ? `No response from ${a.address}.` : `PING ${a.address}:
@@ -11439,7 +11478,7 @@ ${result}`;
11439
11478
  async handler(a, ctx) {
11440
11479
  ctx.info(`Tracerouting ${a.address} (count=${a.count})`);
11441
11480
  const cmd = new Cmd(`/tool traceroute ${a.address}`).set("count", a.count).flag("use-dns", a.use_dns).build();
11442
- const result = await executeMikrotikCommand(cmd, ctx);
11481
+ const result = flattenLiveOutput(await executeMikrotikCommand(cmd, ctx, { maxMs: 30000 + a.count * 3000 }));
11443
11482
  if (looksLikeError(result))
11444
11483
  return `Failed to traceroute ${a.address}: ${result}`;
11445
11484
  return isEmpty(result) ? `No route information for ${a.address}.` : `TRACEROUTE ${a.address}:
@@ -11463,7 +11502,7 @@ ${result}`;
11463
11502
  async handler(a, ctx) {
11464
11503
  ctx.info(`Bandwidth test to ${a.address} (duration=${a.duration}s, direction=${a.direction})`);
11465
11504
  const cmd = new Cmd(`/tool bandwidth-test ${a.address}`).set("duration", `${a.duration}s`).set("direction", a.direction).set("protocol", a.protocol).opt("user", a.user).opt("password", a.password).build();
11466
- const result = await executeMikrotikCommand(cmd, ctx);
11505
+ const result = flattenLiveOutput(await executeMikrotikCommand(cmd, ctx, { maxMs: a.duration * 1000 + 12000 }));
11467
11506
  if (looksLikeError(result))
11468
11507
  return `Failed to run bandwidth test to ${a.address}: ${result}`;
11469
11508
  return isEmpty(result) ? `No bandwidth test results for ${a.address}.` : `BANDWIDTH TEST:
@@ -12115,7 +12154,7 @@ var floodPingTools = [
12115
12154
  async handler(a, ctx) {
12116
12155
  ctx.info(`Flood-pinging ${a.address} (count=${a.count})`);
12117
12156
  const cmd = new Cmd(`/tool flood-ping ${a.address}`).set("count", a.count).opt("size", a.size).opt("interface", a.interface).opt("src-address", a.src_address).build();
12118
- const result = await executeMikrotikCommand(cmd, ctx);
12157
+ const result = flattenLiveOutput(await executeMikrotikCommand(cmd, ctx, { maxMs: 30000 }));
12119
12158
  if (looksLikeError(result))
12120
12159
  return `Failed to flood-ping ${a.address}: ${result}`;
12121
12160
  return isEmpty(result) ? `No response from ${a.address}.` : `FLOOD PING ${a.address}:
@@ -12779,7 +12818,7 @@ var speedTestTools = [
12779
12818
  async handler(a, ctx) {
12780
12819
  ctx.info(`Speed test to ${a.address} (duration=${a.duration}s, direction=${a.direction})`);
12781
12820
  const cmd = new Cmd(`/tool speed-test address=${a.address}`).set("duration", `${a.duration}s`).set("direction", a.direction).opt("tcp-connection-count", a.tcp_connection_count).opt("user", a.user).opt("password", a.password).build();
12782
- const result = await executeMikrotikCommand(cmd, ctx);
12821
+ const result = flattenLiveOutput(await executeMikrotikCommand(cmd, ctx, { maxMs: a.duration * 1000 + 15000 }));
12783
12822
  if (looksLikeError(result))
12784
12823
  return `Failed to run speed test to ${a.address}: ${result}`;
12785
12824
  return isEmpty(result) ? `No speed-test results for ${a.address}.` : `SPEED TEST:
@@ -17566,7 +17605,7 @@ var safeModeTools = [
17566
17605
  async handler(_a, ctx) {
17567
17606
  const device = resolveDeviceName(ctx.device);
17568
17607
  ctx.info(`[${device}] Committing safe mode changes`);
17569
- return getSafeModeManager(device).commit();
17608
+ return (await getSafeModeManager(device).commit()).message;
17570
17609
  }
17571
17610
  }),
17572
17611
  defineTool({
@@ -22322,7 +22361,7 @@ function selectToolModules(filter = {}, catalog = moduleCatalog) {
22322
22361
  // package.json
22323
22362
  var package_default = {
22324
22363
  name: "@usex/mikrotik-mcp",
22325
- version: "3.20.0",
22364
+ version: "3.22.0",
22326
22365
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
22327
22366
  keywords: [
22328
22367
  "ai",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usex/mikrotik-mcp",
3
- "version": "3.20.0",
3
+ "version": "3.22.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",