@usex/mikrotik-mcp 3.21.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
@@ -894,31 +894,39 @@ class SafeModeManager {
894
894
  }
895
895
  commit() {
896
896
  return this.lock(async () => {
897
- if (!this.active || !this.channel)
898
- return "Safe mode is not active. Nothing to commit.";
899
- this.channel.write(`
900
- `);
901
- const probe = await this.readSettledPrompt();
902
- 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();
903
901
  if (before === "released") {
904
902
  this.cleanup();
905
- 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
+ };
906
907
  }
907
908
  if (before === "unknown") {
908
- 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
+ };
909
913
  }
910
914
  this.channel.write(CTRL_X);
911
- this.channel.write(`
912
- `);
913
- const after = await this.readSettledPrompt();
914
- switch (classifyPrompt(after)) {
915
+ const after = await this.probeMode();
916
+ switch (after) {
915
917
  case "released":
916
918
  this.cleanup();
917
- return "Changes committed successfully. Safe mode DISABLED.";
919
+ return { ok: true, message: "Changes committed successfully. Safe mode DISABLED." };
918
920
  case "safe":
919
- 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
+ };
920
925
  default:
921
- 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
+ };
922
930
  }
923
931
  });
924
932
  }
@@ -955,32 +963,17 @@ class SafeModeManager {
955
963
  channel.on("data", onData);
956
964
  });
957
965
  }
958
- readSettledPrompt(quietMs = 450, maxMs = 8000) {
959
- const channel = this.channel;
960
- if (!channel)
961
- return Promise.resolve("");
962
- return new Promise((resolve2) => {
963
- let buf = "";
964
- let quiet;
965
- let hard;
966
- function done() {
967
- clearTimeout(hard);
968
- if (quiet)
969
- clearTimeout(quiet);
970
- channel.removeListener("data", onData);
971
- resolve2(stripAnsi(buf));
972
- }
973
- function onData(chunk) {
974
- buf += decodeOutput(chunk);
975
- if (PROMPT_RE.test(lastNonEmptyLine(stripAnsi(buf)))) {
976
- if (quiet)
977
- clearTimeout(quiet);
978
- quiet = setTimeout(done, quietMs);
979
- }
980
- }
981
- hard = setTimeout(done, maxMs);
982
- 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));
983
975
  });
976
+ return classifyPrompt(out);
984
977
  }
985
978
  extractOutput(raw, command) {
986
979
  const text = raw.replace(/\r\n/g, `
@@ -2632,11 +2625,19 @@ async function restoreLocalBackup(device, name, confirm) {
2632
2625
  return fail("device stopped responding after applying \u2014 rolled back to avoid a lock-out", applied);
2633
2626
  }
2634
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
+ }
2635
2636
  return {
2636
2637
  ok: true,
2637
2638
  applied,
2638
2639
  committed: true,
2639
- message: `committed ${applied} command(s). ${committed}`
2640
+ message: `committed ${applied} command(s). ${committed.message}`
2640
2641
  };
2641
2642
  } catch (e) {
2642
2643
  await safe.rollback();
@@ -3870,9 +3871,14 @@ DRY-RUN: all changes rolled back. Re-run with confirm=true to commit.`;
3870
3871
  ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`;
3871
3872
  }
3872
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
+ }
3873
3879
  return `${header}
3874
3880
 
3875
- COMMITTED: changes are now permanent. ${committed}`;
3881
+ COMMITTED: changes are now permanent. ${committed.message}`;
3876
3882
  } catch (e) {
3877
3883
  await safe.rollback();
3878
3884
  const msg = e instanceof Error ? e.message : String(e);
@@ -5935,8 +5941,12 @@ ${useSafe ? "Safe Mode rolled back \u2014 no changes kept." : "Partial rules rem
5935
5941
  }
5936
5942
  done.push(cmd);
5937
5943
  }
5938
- if (useSafe)
5939
- 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
+ }
5940
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.`;
5941
5951
  }
5942
5952
  }),
@@ -17583,7 +17593,7 @@ var safeModeTools = [
17583
17593
  async handler(_a, ctx) {
17584
17594
  const device = resolveDeviceName(ctx.device);
17585
17595
  ctx.info(`[${device}] Committing safe mode changes`);
17586
- return getSafeModeManager(device).commit();
17596
+ return (await getSafeModeManager(device).commit()).message;
17587
17597
  }
17588
17598
  }),
17589
17599
  defineTool({
@@ -23951,7 +23961,7 @@ function registerPrompts(server) {
23951
23961
  // package.json
23952
23962
  var package_default = {
23953
23963
  name: "@usex/mikrotik-mcp",
23954
- version: "3.21.0",
23964
+ version: "3.22.0",
23955
23965
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
23956
23966
  keywords: [
23957
23967
  "ai",
package/dist/index.d.ts CHANGED
@@ -163,6 +163,16 @@ declare class MikroTikSSHClient {
163
163
  /** Close the SSH connection. Safe to call multiple times. */
164
164
  disconnect(): void;
165
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
+ }
166
176
  declare class SafeModeManager {
167
177
  private readonly deviceName;
168
178
  private ssh;
@@ -179,8 +189,12 @@ declare class SafeModeManager {
179
189
  enable(): Promise<string>;
180
190
  /** Execute a command through the safe-mode persistent shell session. */
181
191
  execute(command: string): Promise<string>;
182
- /** Send Ctrl+X again to exit Safe Mode and persist all changes. */
183
- 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>;
184
198
  /** Close the session to trigger MikroTik's automatic safe-mode revert. */
185
199
  rollback(): Promise<string>;
186
200
  status(): string;
@@ -192,13 +206,14 @@ declare class SafeModeManager {
192
206
  */
193
207
  private readUntilPrompt;
194
208
  /**
195
- * Read until the output SETTLES: once any prompt is visible, wait for a quiet
196
- * gap (`quietMs` with no new bytes) before resolving, or give up at `maxMs`.
197
- * Used by commit, where Ctrl+X + Enter can emit a transient `<SAFE>` redraw
198
- * followed by the real post-commit prompt settling on the LAST prompt after
199
- * 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).
200
215
  */
201
- private readSettledPrompt;
216
+ private probeMode;
202
217
  private extractOutput;
203
218
  private cleanup;
204
219
  }
package/dist/index.js CHANGED
@@ -880,31 +880,39 @@ class SafeModeManager {
880
880
  }
881
881
  commit() {
882
882
  return this.lock(async () => {
883
- if (!this.active || !this.channel)
884
- return "Safe mode is not active. Nothing to commit.";
885
- this.channel.write(`
886
- `);
887
- const probe = await this.readSettledPrompt();
888
- 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();
889
887
  if (before === "released") {
890
888
  this.cleanup();
891
- 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
+ };
892
893
  }
893
894
  if (before === "unknown") {
894
- 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
+ };
895
899
  }
896
900
  this.channel.write(CTRL_X);
897
- this.channel.write(`
898
- `);
899
- const after = await this.readSettledPrompt();
900
- switch (classifyPrompt(after)) {
901
+ const after = await this.probeMode();
902
+ switch (after) {
901
903
  case "released":
902
904
  this.cleanup();
903
- return "Changes committed successfully. Safe mode DISABLED.";
905
+ return { ok: true, message: "Changes committed successfully. Safe mode DISABLED." };
904
906
  case "safe":
905
- 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
+ };
906
911
  default:
907
- 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
+ };
908
916
  }
909
917
  });
910
918
  }
@@ -941,32 +949,17 @@ class SafeModeManager {
941
949
  channel.on("data", onData);
942
950
  });
943
951
  }
944
- readSettledPrompt(quietMs = 450, maxMs = 8000) {
945
- const channel = this.channel;
946
- if (!channel)
947
- return Promise.resolve("");
948
- return new Promise((resolve2) => {
949
- let buf = "";
950
- let quiet;
951
- let hard;
952
- function done() {
953
- clearTimeout(hard);
954
- if (quiet)
955
- clearTimeout(quiet);
956
- channel.removeListener("data", onData);
957
- resolve2(stripAnsi(buf));
958
- }
959
- function onData(chunk) {
960
- buf += decodeOutput(chunk);
961
- if (PROMPT_RE.test(lastNonEmptyLine(stripAnsi(buf)))) {
962
- if (quiet)
963
- clearTimeout(quiet);
964
- quiet = setTimeout(done, quietMs);
965
- }
966
- }
967
- hard = setTimeout(done, maxMs);
968
- 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));
969
961
  });
962
+ return classifyPrompt(out);
970
963
  }
971
964
  extractOutput(raw, command) {
972
965
  const text = raw.replace(/\r\n/g, `
@@ -2644,11 +2637,19 @@ async function restoreLocalBackup(device, name, confirm) {
2644
2637
  return fail("device stopped responding after applying \u2014 rolled back to avoid a lock-out", applied);
2645
2638
  }
2646
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
+ }
2647
2648
  return {
2648
2649
  ok: true,
2649
2650
  applied,
2650
2651
  committed: true,
2651
- message: `committed ${applied} command(s). ${committed}`
2652
+ message: `committed ${applied} command(s). ${committed.message}`
2652
2653
  };
2653
2654
  } catch (e) {
2654
2655
  await safe.rollback();
@@ -3882,9 +3883,14 @@ DRY-RUN: all changes rolled back. Re-run with confirm=true to commit.`;
3882
3883
  ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`;
3883
3884
  }
3884
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
+ }
3885
3891
  return `${header}
3886
3892
 
3887
- COMMITTED: changes are now permanent. ${committed}`;
3893
+ COMMITTED: changes are now permanent. ${committed.message}`;
3888
3894
  } catch (e) {
3889
3895
  await safe.rollback();
3890
3896
  const msg = e instanceof Error ? e.message : String(e);
@@ -5947,8 +5953,12 @@ ${useSafe ? "Safe Mode rolled back \u2014 no changes kept." : "Partial rules rem
5947
5953
  }
5948
5954
  done.push(cmd);
5949
5955
  }
5950
- if (useSafe)
5951
- 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
+ }
5952
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.`;
5953
5963
  }
5954
5964
  }),
@@ -17595,7 +17605,7 @@ var safeModeTools = [
17595
17605
  async handler(_a, ctx) {
17596
17606
  const device = resolveDeviceName(ctx.device);
17597
17607
  ctx.info(`[${device}] Committing safe mode changes`);
17598
- return getSafeModeManager(device).commit();
17608
+ return (await getSafeModeManager(device).commit()).message;
17599
17609
  }
17600
17610
  }),
17601
17611
  defineTool({
@@ -22351,7 +22361,7 @@ function selectToolModules(filter = {}, catalog = moduleCatalog) {
22351
22361
  // package.json
22352
22362
  var package_default = {
22353
22363
  name: "@usex/mikrotik-mcp",
22354
- version: "3.21.0",
22364
+ version: "3.22.0",
22355
22365
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
22356
22366
  keywords: [
22357
22367
  "ai",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usex/mikrotik-mcp",
3
- "version": "3.21.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",