@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 +98 -59
- package/dist/index.d.ts +31 -10
- package/dist/index.js +98 -59
- package/package.json +1 -1
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
177
|
-
|
|
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
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* a
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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.
|
|
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