@usex/mikrotik-mcp 3.28.0 → 3.30.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
@@ -23,7 +23,8 @@ var McpServerSettingsSchema = z.object({
23
23
  allowedHosts: z.string().default(""),
24
24
  allowedOrigins: z.string().default(""),
25
25
  corsOrigins: z.string().default(""),
26
- toolPageSize: z.coerce.number().int().min(0).default(0)
26
+ toolPageSize: z.coerce.number().int().min(0).default(0),
27
+ appViews: z.boolean().default(true)
27
28
  });
28
29
  var DeviceConfigSchema = z.object({
29
30
  host: z.string().default("127.0.0.1"),
@@ -121,7 +122,8 @@ function parseDevicesSource(raw, fromFile) {
121
122
  const s3 = structured && obj.s3 && typeof obj.s3 === "object" ? obj.s3 : undefined;
122
123
  const dashboard = structured && obj.dashboard && typeof obj.dashboard === "object" ? obj.dashboard : undefined;
123
124
  const tools = structured && obj.tools && typeof obj.tools === "object" ? obj.tools : undefined;
124
- return { devices, defaultDevice, s3, dashboard, tools };
125
+ const mcp = structured && obj.mcp && typeof obj.mcp === "object" ? obj.mcp : undefined;
126
+ return { devices, defaultDevice, s3, dashboard, tools, mcp };
125
127
  }
126
128
  var configSource = { path: DEFAULT_CONFIG_FILE, fromFile: false };
127
129
  function getConfigSource() {
@@ -157,6 +159,7 @@ function loadConfig(argv = process.argv.slice(2)) {
157
159
  let fileS3 = {};
158
160
  let fileDashboard = {};
159
161
  let fileTools;
162
+ let fileMcp;
160
163
  if (configFile || devicesInline) {
161
164
  const src = configFile ? parseDevicesSource(configFile, true) : parseDevicesSource(devicesInline, false);
162
165
  for (const [name, dc] of Object.entries(src.devices))
@@ -168,6 +171,7 @@ function loadConfig(argv = process.argv.slice(2)) {
168
171
  fileS3 = src.s3;
169
172
  fileDashboard = src.dashboard;
170
173
  fileTools = src.tools;
174
+ fileMcp = src.mcp;
171
175
  }
172
176
  const s3 = {
173
177
  accessKeyId: pick("s3-access-key-id", "S3_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID"),
@@ -180,6 +184,8 @@ function loadConfig(argv = process.argv.slice(2)) {
180
184
  presignExpiresIn: pick("s3-presign-expires-in", "MIKROTIK_S3_PRESIGN_EXPIRES_IN"),
181
185
  ...fileS3
182
186
  };
187
+ const appViewsRaw = pick("app-views", "MIKROTIK_MCP__APP_VIEWS");
188
+ const appViewsEnv = appViewsRaw === undefined ? undefined : !/^(0|false|no|off)$/i.test(appViewsRaw);
183
189
  const mcp = {
184
190
  transport: pick("transport", "MIKROTIK_MCP__TRANSPORT", "MCP_TRANSPORT"),
185
191
  host: pick("mcp-host", "MIKROTIK_MCP__HOST"),
@@ -187,7 +193,9 @@ function loadConfig(argv = process.argv.slice(2)) {
187
193
  allowedHosts: pick("mcp-allowed-hosts", "MIKROTIK_MCP__ALLOWED_HOSTS"),
188
194
  allowedOrigins: pick("mcp-allowed-origins", "MIKROTIK_MCP__ALLOWED_ORIGINS"),
189
195
  corsOrigins: pick("mcp-cors-origins", "MIKROTIK_MCP__CORS_ORIGINS"),
190
- toolPageSize: pick("tool-page-size", "MIKROTIK_MCP__TOOL_PAGE_SIZE")
196
+ toolPageSize: pick("tool-page-size", "MIKROTIK_MCP__TOOL_PAGE_SIZE"),
197
+ appViews: appViewsEnv,
198
+ ...fileMcp
191
199
  };
192
200
  const isTruthy = (v) => /^(1|true|yes|on)$/i.test(v ?? "");
193
201
  const readOnly = isTruthy(pick("read-only", "MIKROTIK_READ_ONLY"));
@@ -779,6 +787,29 @@ class MikroTikSSHClient {
779
787
  });
780
788
  });
781
789
  }
790
+ uploadFile(remotePath, data) {
791
+ if (!this.client) {
792
+ return Promise.reject(new Error("Not connected to MikroTik device"));
793
+ }
794
+ const openSftp = this.client.sftp.bind(this.client);
795
+ return new Promise((resolve2, reject) => {
796
+ openSftp((err, sftp) => {
797
+ if (err) {
798
+ reject(new Error(`SFTP subsystem unavailable: ${err.message}`));
799
+ return;
800
+ }
801
+ sftp.writeFile(remotePath, data, (werr) => {
802
+ try {
803
+ sftp.end();
804
+ } catch {}
805
+ if (werr)
806
+ reject(new Error(`SFTP write failed: ${werr.message}`));
807
+ else
808
+ resolve2();
809
+ });
810
+ });
811
+ });
812
+ }
782
813
  shell(opts = {}) {
783
814
  if (!this.client) {
784
815
  return Promise.reject(new Error("Not connected to MikroTik device"));
@@ -911,13 +942,13 @@ class SafeModeManager {
911
942
  }
912
943
  this.ssh = ssh;
913
944
  this.channel = await ssh.shell({ term: "dumb", cols: 220, rows: 50 });
914
- const initial = await this.readUntilPrompt(20000);
945
+ const initial = (await this.readUntilPrompt(20000)).text;
915
946
  if (!PROMPT_RE.test(initial)) {
916
947
  this.cleanup();
917
948
  return `Error: Timed out waiting for MikroTik shell prompt. Got: ${JSON.stringify(initial.slice(0, 300))}`;
918
949
  }
919
950
  this.channel.write(CTRL_X);
920
- const response = await this.readUntilPrompt(1e4, (c) => isSafeModeActivated(c));
951
+ const response = (await this.readUntilPrompt(1e4, (c) => isSafeModeActivated(c))).text;
921
952
  if (!isSafeModeActivated(response)) {
922
953
  this.cleanup();
923
954
  return `Error: Safe mode did not activate. Response: ${JSON.stringify(response.slice(0, 300))}`;
@@ -933,8 +964,11 @@ class SafeModeManager {
933
964
  }
934
965
  this.channel.write(`${command}
935
966
  `);
936
- const raw = await this.readUntilPrompt();
937
- return this.extractOutput(raw, command);
967
+ const { text, timedOut } = await this.readUntilPrompt();
968
+ if (timedOut) {
969
+ throw new Error(`Safe Mode shell did not return a prompt within 15s (command: ${command}). The interactive ` + "session appears wedged \u2014 some RouterOS builds/terminals don't support Safe Mode over SSH. " + "Apply the change with the direct write tools instead (verify each with a read).");
970
+ }
971
+ return this.extractOutput(text, command);
938
972
  });
939
973
  }
940
974
  commit() {
@@ -989,7 +1023,7 @@ class SafeModeManager {
989
1023
  readUntilPrompt(timeoutMs = 15000, isDone = (c) => PROMPT_RE.test(c)) {
990
1024
  const channel = this.channel;
991
1025
  if (!channel)
992
- return Promise.resolve("");
1026
+ return Promise.resolve({ text: "", timedOut: false });
993
1027
  return new Promise((resolve2) => {
994
1028
  let buf = "";
995
1029
  let timer;
@@ -997,14 +1031,14 @@ class SafeModeManager {
997
1031
  buf += decodeOutput(chunk);
998
1032
  const cleaned = stripAnsi(buf);
999
1033
  if (isDone(cleaned))
1000
- finish(cleaned);
1034
+ finish(cleaned, false);
1001
1035
  }
1002
- function finish(result) {
1036
+ function finish(result, timedOut) {
1003
1037
  clearTimeout(timer);
1004
1038
  channel.removeListener("data", onData);
1005
- resolve2(result);
1039
+ resolve2({ text: result, timedOut });
1006
1040
  }
1007
- timer = setTimeout(() => finish(stripAnsi(buf)), timeoutMs);
1041
+ timer = setTimeout(() => finish(stripAnsi(buf), true), timeoutMs);
1008
1042
  channel.on("data", onData);
1009
1043
  });
1010
1044
  }
@@ -1014,10 +1048,10 @@ class SafeModeManager {
1014
1048
  const token = "__MCP_SAFEMODE_PROBE__";
1015
1049
  this.channel.write(`:put "${token}"
1016
1050
  `);
1017
- const out = await this.readUntilPrompt(8000, (cleaned) => {
1051
+ const out = (await this.readUntilPrompt(8000, (cleaned) => {
1018
1052
  const i = cleaned.lastIndexOf(token);
1019
1053
  return i >= 0 && PROMPT_RE.test(cleaned.slice(i));
1020
- });
1054
+ })).text;
1021
1055
  return classifyPrompt(out);
1022
1056
  }
1023
1057
  extractOutput(raw, command) {
@@ -1090,6 +1124,31 @@ async function executeMikrotikCommand(command, ctx, opts) {
1090
1124
  ctx.info(`[${deviceName}] Executing MikroTik command: ${command}`);
1091
1125
  return runOnce(command, ctx.device, opts);
1092
1126
  }
1127
+ async function uploadFileToDevice(deviceName, remotePath, data) {
1128
+ const name = resolveDeviceName(deviceName);
1129
+ const dc = getDevice(deviceName);
1130
+ if (isMacTelnetDevice(dc)) {
1131
+ throw new Error(`Cannot transfer a file to '${name}': it is reached over Layer-2 MAC-Telnet, which has no file ` + "transfer. Configure SSH (host + credentials) for this device, or have the router pull the file " + "itself with /tool fetch from a URL it can reach.");
1132
+ }
1133
+ const ssh = new MikroTikSSHClient({
1134
+ host: dc.host,
1135
+ username: dc.username,
1136
+ password: dc.password,
1137
+ keyFilename: dc.keyFilename,
1138
+ privateKey: dc.privateKey,
1139
+ keyPassphrase: dc.keyPassphrase,
1140
+ port: dc.port,
1141
+ timeoutMs: dc.timeoutMs
1142
+ });
1143
+ if (!await ssh.connect()) {
1144
+ throw new Error(connectErrorMessage(name, dc, ssh.lastError));
1145
+ }
1146
+ try {
1147
+ await ssh.uploadFile(remotePath, data);
1148
+ } finally {
1149
+ ssh.disconnect();
1150
+ }
1151
+ }
1093
1152
 
1094
1153
  // src/core/registry.ts
1095
1154
  import { z as z2 } from "zod";
@@ -1743,9 +1802,9 @@ function defineTool(def) {
1743
1802
  inputSchema: def.inputSchema,
1744
1803
  ui: def.ui,
1745
1804
  register(server, opts = {}) {
1746
- const { sendLog, deviceNames, deviceAliases, deviceDirectory: deviceDirectory2 } = opts;
1805
+ const { sendLog, deviceNames, deviceAliases, deviceDirectory: deviceDirectory2, appViews } = opts;
1747
1806
  const multiDevice = !!deviceNames && deviceNames.length > 1;
1748
- const { ui, auto } = effectiveUi(def);
1807
+ const { ui, auto } = appViews === false ? { ui: undefined, auto: false } : effectiveUi(def);
1749
1808
  const selectorNames = multiDevice && deviceNames ? [...new Set([...deviceNames, ...deviceAliases ?? []])] : [];
1750
1809
  const inputSchema = multiDevice ? {
1751
1810
  ...def.inputSchema,
@@ -2803,21 +2862,29 @@ ${fileDetails}` : `Section export '${name}.rsc' created successfully.`;
2803
2862
  }),
2804
2863
  defineTool({
2805
2864
  name: "upload_file",
2806
- title: "Upload File to Device (Simulated)",
2865
+ title: "Upload File to Device",
2807
2866
  annotations: WRITE,
2808
- description: "Accepts a base64-encoded file intended for the device filesystem. NOTE: the current handler" + " only validates the base64 encoding and returns a simulated success \u2014 it does not transfer" + " bytes to the device over SSH. Once a `.backup` file is present on the device apply it with" + " `restore_backup`; for `.rsc` configuration scripts use `import_configuration`.",
2867
+ description: "Transfer a file to the device filesystem over SFTP (the file subsystem RouterOS exposes on its SSH" + " server) \u2014 this actually pushes the bytes, then verifies the file appears in `/file`. Use it to put" + " a file on the router before another tool can use it: a `.rsc` config script (then apply with" + " `import_configuration`), a `.backup` file (then `restore_backup`), or a certificate/key (then" + " `import_certificate`). `filename` is the DESTINATION path on the device \u2014 root by default" + " (e.g. `config.rsc`), or an external-disk path (e.g. `disk1/cert.pem`). `content_base64` is the file's" + " raw bytes, base64-encoded (binary-safe \u2014 works for `.backup`/cert files, not just text). Overwrites" + " any existing file of the same name. NOT available on MAC-Telnet devices (Layer-2 has no file" + " transfer) \u2014 there, have the router pull the file itself with `/tool fetch` from a reachable URL.",
2809
2868
  inputSchema: {
2810
- filename: z5.string(),
2811
- content_base64: z5.string()
2869
+ filename: z5.string().describe("Destination path on the device, e.g. 'config.rsc' or 'disk1/cert.pem'"),
2870
+ content_base64: z5.string().describe("File bytes, base64-encoded (binary-safe)")
2812
2871
  },
2813
2872
  async handler(a, ctx) {
2814
2873
  ctx.info(`Uploading file: filename=${a.filename}`);
2874
+ const data = Buffer.from(a.content_base64, "base64");
2875
+ if (data.length === 0) {
2876
+ return "Nothing to upload: content_base64 is empty or decoded to 0 bytes.";
2877
+ }
2815
2878
  try {
2816
- Buffer.from(a.content_base64, "base64").toString("utf8");
2879
+ await uploadFileToDevice(ctx.device, a.filename, data);
2817
2880
  } catch (e) {
2818
- return `Failed to decode file content: ${e instanceof Error ? e.message : String(e)}`;
2881
+ return `Failed to upload '${a.filename}': ${e instanceof Error ? e.message : String(e)}`;
2882
+ }
2883
+ const verify = await executeMikrotikCommand(`/file print count-only where name="${a.filename}"`, ctx);
2884
+ if (verify.trim() === "0") {
2885
+ return `File '${a.filename}' was transferred (${data.length} bytes) but did not appear in /file under ` + "that exact name \u2014 RouterOS may have stored it at a slightly different path. Run list_files to " + "confirm, then apply it (import_configuration / restore_backup / import_certificate).";
2819
2886
  }
2820
- return `File '${a.filename}' uploaded successfully (simulated).`;
2887
+ return `File '${a.filename}' uploaded successfully (${data.length} bytes) and is now in /file. Apply it: ` + "import_configuration for a `.rsc`, restore_backup for a `.backup`, or import_certificate for a cert.";
2821
2888
  }
2822
2889
  }),
2823
2890
  defineTool({
@@ -4383,10 +4450,17 @@ var changePlanTools = [
4383
4450
  const enabled2 = await safe.enable();
4384
4451
  if (enabled2.startsWith("Error"))
4385
4452
  return enabled2;
4453
+ const APPLY_BUDGET_MS = 90000;
4454
+ const startedAt = Date.now();
4455
+ const overBudget = () => Date.now() - startedAt > APPLY_BUDGET_MS;
4386
4456
  try {
4387
4457
  const before = normalizeExport(await safe.execute("/export terse"));
4388
4458
  const log = [];
4389
4459
  for (const step of plan.steps) {
4460
+ if (overBudget()) {
4461
+ await safe.rollback();
4462
+ 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.";
4463
+ }
4390
4464
  const out = await safe.execute(step.command);
4391
4465
  if (looksLikeError(out)) {
4392
4466
  await safe.rollback();
@@ -27001,7 +27075,7 @@ function registerPrompts(server) {
27001
27075
  // package.json
27002
27076
  var package_default = {
27003
27077
  name: "@usex/mikrotik-mcp",
27004
- version: "3.28.0",
27078
+ version: "3.30.0",
27005
27079
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
27006
27080
  keywords: [
27007
27081
  "ai",
@@ -27207,6 +27281,7 @@ function createServer(opts = {}) {
27207
27281
  deviceNames: names,
27208
27282
  deviceAliases: deviceLabels(),
27209
27283
  deviceDirectory: deviceDirectory(),
27284
+ appViews: getConfig().mcp.appViews,
27210
27285
  readOnly
27211
27286
  });
27212
27287
  const promptCount = registerPrompts(server);
package/dist/index.d.ts CHANGED
@@ -94,6 +94,14 @@ interface RegisterOptions {
94
94
  */
95
95
  deviceDirectory?: DeviceDirectoryEntry[];
96
96
  /**
97
+ * Emit MCP App view metadata (`_meta.ui`) on tools. Default true. Set false for
98
+ * hosts whose tool discovery hides/deprioritises tools that carry App/
99
+ * `openai/outputTemplate` metadata — without it, every `list_*`/`get_*` read
100
+ * tool gains the auto-records view and some clients then surface only the
101
+ * (metadata-free) write tools. Disabling makes reads plain, surfacing tools.
102
+ */
103
+ appViews?: boolean;
104
+ /**
97
105
  * Read-only mode: register only tools annotated `readOnlyHint`. Used to
98
106
  * withhold every write/destructive tool from a publicly-exposed surface (e.g.
99
107
  * a ChatGPT Apps connector) until authentication is in place.
@@ -182,6 +190,14 @@ declare class MikroTikSSHClient {
182
190
  run(command: string, opts?: {
183
191
  maxMs?: number;
184
192
  }): Promise<string>;
193
+ /**
194
+ * Upload a file's bytes to the device over SFTP — the file-transfer subsystem
195
+ * RouterOS exposes on its SSH server. `remotePath` is relative to the SFTP
196
+ * default directory (the flash root), so `config.rsc` lands at the root and
197
+ * appears in `/file`; a path like `disk1/config.rsc` targets external disk.
198
+ * Resolves on success; rejects with a clear reason on failure.
199
+ */
200
+ uploadFile(remotePath: string, data: Buffer): Promise<void>;
185
201
  /** Open a persistent interactive shell channel (used by Safe Mode). */
186
202
  shell(opts?: {
187
203
  term?: string;
@@ -215,7 +231,14 @@ declare class SafeModeManager {
215
231
  private lock;
216
232
  /** Open a persistent SSH shell and activate MikroTik Safe Mode. */
217
233
  enable(): Promise<string>;
218
- /** Execute a command through the safe-mode persistent shell session. */
234
+ /**
235
+ * Execute a command through the safe-mode persistent shell session.
236
+ *
237
+ * If the shell never returns a prompt within the timeout, the interactive
238
+ * Safe-Mode session is wedged — this throws immediately so the caller aborts
239
+ * instead of issuing more commands that would each also burn the full timeout
240
+ * (the cumulative effect of which is a multi-minute "hang" to the MCP client).
241
+ */
219
242
  execute(command: string): Promise<string>;
220
243
  /**
221
244
  * Send Ctrl+X again to exit Safe Mode and persist all changes. Returns a
package/dist/index.js CHANGED
@@ -19,7 +19,8 @@ var McpServerSettingsSchema = z.object({
19
19
  allowedHosts: z.string().default(""),
20
20
  allowedOrigins: z.string().default(""),
21
21
  corsOrigins: z.string().default(""),
22
- toolPageSize: z.coerce.number().int().min(0).default(0)
22
+ toolPageSize: z.coerce.number().int().min(0).default(0),
23
+ appViews: z.boolean().default(true)
23
24
  });
24
25
  var DeviceConfigSchema = z.object({
25
26
  host: z.string().default("127.0.0.1"),
@@ -117,7 +118,8 @@ function parseDevicesSource(raw, fromFile) {
117
118
  const s3 = structured && obj.s3 && typeof obj.s3 === "object" ? obj.s3 : undefined;
118
119
  const dashboard = structured && obj.dashboard && typeof obj.dashboard === "object" ? obj.dashboard : undefined;
119
120
  const tools = structured && obj.tools && typeof obj.tools === "object" ? obj.tools : undefined;
120
- return { devices, defaultDevice, s3, dashboard, tools };
121
+ const mcp = structured && obj.mcp && typeof obj.mcp === "object" ? obj.mcp : undefined;
122
+ return { devices, defaultDevice, s3, dashboard, tools, mcp };
121
123
  }
122
124
  var configSource = { path: DEFAULT_CONFIG_FILE, fromFile: false };
123
125
  function loadConfig(argv = process.argv.slice(2)) {
@@ -150,6 +152,7 @@ function loadConfig(argv = process.argv.slice(2)) {
150
152
  let fileS3 = {};
151
153
  let fileDashboard = {};
152
154
  let fileTools;
155
+ let fileMcp;
153
156
  if (configFile || devicesInline) {
154
157
  const src = configFile ? parseDevicesSource(configFile, true) : parseDevicesSource(devicesInline, false);
155
158
  for (const [name, dc] of Object.entries(src.devices))
@@ -161,6 +164,7 @@ function loadConfig(argv = process.argv.slice(2)) {
161
164
  fileS3 = src.s3;
162
165
  fileDashboard = src.dashboard;
163
166
  fileTools = src.tools;
167
+ fileMcp = src.mcp;
164
168
  }
165
169
  const s3 = {
166
170
  accessKeyId: pick("s3-access-key-id", "S3_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID"),
@@ -173,6 +177,8 @@ function loadConfig(argv = process.argv.slice(2)) {
173
177
  presignExpiresIn: pick("s3-presign-expires-in", "MIKROTIK_S3_PRESIGN_EXPIRES_IN"),
174
178
  ...fileS3
175
179
  };
180
+ const appViewsRaw = pick("app-views", "MIKROTIK_MCP__APP_VIEWS");
181
+ const appViewsEnv = appViewsRaw === undefined ? undefined : !/^(0|false|no|off)$/i.test(appViewsRaw);
176
182
  const mcp = {
177
183
  transport: pick("transport", "MIKROTIK_MCP__TRANSPORT", "MCP_TRANSPORT"),
178
184
  host: pick("mcp-host", "MIKROTIK_MCP__HOST"),
@@ -180,7 +186,9 @@ function loadConfig(argv = process.argv.slice(2)) {
180
186
  allowedHosts: pick("mcp-allowed-hosts", "MIKROTIK_MCP__ALLOWED_HOSTS"),
181
187
  allowedOrigins: pick("mcp-allowed-origins", "MIKROTIK_MCP__ALLOWED_ORIGINS"),
182
188
  corsOrigins: pick("mcp-cors-origins", "MIKROTIK_MCP__CORS_ORIGINS"),
183
- toolPageSize: pick("tool-page-size", "MIKROTIK_MCP__TOOL_PAGE_SIZE")
189
+ toolPageSize: pick("tool-page-size", "MIKROTIK_MCP__TOOL_PAGE_SIZE"),
190
+ appViews: appViewsEnv,
191
+ ...fileMcp
184
192
  };
185
193
  const isTruthy = (v) => /^(1|true|yes|on)$/i.test(v ?? "");
186
194
  const readOnly = isTruthy(pick("read-only", "MIKROTIK_READ_ONLY"));
@@ -771,6 +779,29 @@ class MikroTikSSHClient {
771
779
  });
772
780
  });
773
781
  }
782
+ uploadFile(remotePath, data) {
783
+ if (!this.client) {
784
+ return Promise.reject(new Error("Not connected to MikroTik device"));
785
+ }
786
+ const openSftp = this.client.sftp.bind(this.client);
787
+ return new Promise((resolve2, reject) => {
788
+ openSftp((err, sftp) => {
789
+ if (err) {
790
+ reject(new Error(`SFTP subsystem unavailable: ${err.message}`));
791
+ return;
792
+ }
793
+ sftp.writeFile(remotePath, data, (werr) => {
794
+ try {
795
+ sftp.end();
796
+ } catch {}
797
+ if (werr)
798
+ reject(new Error(`SFTP write failed: ${werr.message}`));
799
+ else
800
+ resolve2();
801
+ });
802
+ });
803
+ });
804
+ }
774
805
  shell(opts = {}) {
775
806
  if (!this.client) {
776
807
  return Promise.reject(new Error("Not connected to MikroTik device"));
@@ -897,13 +928,13 @@ class SafeModeManager {
897
928
  }
898
929
  this.ssh = ssh;
899
930
  this.channel = await ssh.shell({ term: "dumb", cols: 220, rows: 50 });
900
- const initial = await this.readUntilPrompt(20000);
931
+ const initial = (await this.readUntilPrompt(20000)).text;
901
932
  if (!PROMPT_RE.test(initial)) {
902
933
  this.cleanup();
903
934
  return `Error: Timed out waiting for MikroTik shell prompt. Got: ${JSON.stringify(initial.slice(0, 300))}`;
904
935
  }
905
936
  this.channel.write(CTRL_X);
906
- const response = await this.readUntilPrompt(1e4, (c) => isSafeModeActivated(c));
937
+ const response = (await this.readUntilPrompt(1e4, (c) => isSafeModeActivated(c))).text;
907
938
  if (!isSafeModeActivated(response)) {
908
939
  this.cleanup();
909
940
  return `Error: Safe mode did not activate. Response: ${JSON.stringify(response.slice(0, 300))}`;
@@ -919,8 +950,11 @@ class SafeModeManager {
919
950
  }
920
951
  this.channel.write(`${command}
921
952
  `);
922
- const raw = await this.readUntilPrompt();
923
- return this.extractOutput(raw, command);
953
+ const { text, timedOut } = await this.readUntilPrompt();
954
+ if (timedOut) {
955
+ throw new Error(`Safe Mode shell did not return a prompt within 15s (command: ${command}). The interactive ` + "session appears wedged \u2014 some RouterOS builds/terminals don't support Safe Mode over SSH. " + "Apply the change with the direct write tools instead (verify each with a read).");
956
+ }
957
+ return this.extractOutput(text, command);
924
958
  });
925
959
  }
926
960
  commit() {
@@ -975,7 +1009,7 @@ class SafeModeManager {
975
1009
  readUntilPrompt(timeoutMs = 15000, isDone = (c) => PROMPT_RE.test(c)) {
976
1010
  const channel = this.channel;
977
1011
  if (!channel)
978
- return Promise.resolve("");
1012
+ return Promise.resolve({ text: "", timedOut: false });
979
1013
  return new Promise((resolve2) => {
980
1014
  let buf = "";
981
1015
  let timer;
@@ -983,14 +1017,14 @@ class SafeModeManager {
983
1017
  buf += decodeOutput(chunk);
984
1018
  const cleaned = stripAnsi(buf);
985
1019
  if (isDone(cleaned))
986
- finish(cleaned);
1020
+ finish(cleaned, false);
987
1021
  }
988
- function finish(result) {
1022
+ function finish(result, timedOut) {
989
1023
  clearTimeout(timer);
990
1024
  channel.removeListener("data", onData);
991
- resolve2(result);
1025
+ resolve2({ text: result, timedOut });
992
1026
  }
993
- timer = setTimeout(() => finish(stripAnsi(buf)), timeoutMs);
1027
+ timer = setTimeout(() => finish(stripAnsi(buf), true), timeoutMs);
994
1028
  channel.on("data", onData);
995
1029
  });
996
1030
  }
@@ -1000,10 +1034,10 @@ class SafeModeManager {
1000
1034
  const token = "__MCP_SAFEMODE_PROBE__";
1001
1035
  this.channel.write(`:put "${token}"
1002
1036
  `);
1003
- const out = await this.readUntilPrompt(8000, (cleaned) => {
1037
+ const out = (await this.readUntilPrompt(8000, (cleaned) => {
1004
1038
  const i = cleaned.lastIndexOf(token);
1005
1039
  return i >= 0 && PROMPT_RE.test(cleaned.slice(i));
1006
- });
1040
+ })).text;
1007
1041
  return classifyPrompt(out);
1008
1042
  }
1009
1043
  extractOutput(raw, command) {
@@ -1076,6 +1110,31 @@ async function executeMikrotikCommand(command, ctx, opts) {
1076
1110
  ctx.info(`[${deviceName}] Executing MikroTik command: ${command}`);
1077
1111
  return runOnce(command, ctx.device, opts);
1078
1112
  }
1113
+ async function uploadFileToDevice(deviceName, remotePath, data) {
1114
+ const name = resolveDeviceName(deviceName);
1115
+ const dc = getDevice(deviceName);
1116
+ if (isMacTelnetDevice(dc)) {
1117
+ throw new Error(`Cannot transfer a file to '${name}': it is reached over Layer-2 MAC-Telnet, which has no file ` + "transfer. Configure SSH (host + credentials) for this device, or have the router pull the file " + "itself with /tool fetch from a URL it can reach.");
1118
+ }
1119
+ const ssh = new MikroTikSSHClient({
1120
+ host: dc.host,
1121
+ username: dc.username,
1122
+ password: dc.password,
1123
+ keyFilename: dc.keyFilename,
1124
+ privateKey: dc.privateKey,
1125
+ keyPassphrase: dc.keyPassphrase,
1126
+ port: dc.port,
1127
+ timeoutMs: dc.timeoutMs
1128
+ });
1129
+ if (!await ssh.connect()) {
1130
+ throw new Error(connectErrorMessage(name, dc, ssh.lastError));
1131
+ }
1132
+ try {
1133
+ await ssh.uploadFile(remotePath, data);
1134
+ } finally {
1135
+ ssh.disconnect();
1136
+ }
1137
+ }
1079
1138
  // src/core/registry.ts
1080
1139
  import { z as z2 } from "zod";
1081
1140
 
@@ -1649,9 +1708,9 @@ function defineTool(def) {
1649
1708
  inputSchema: def.inputSchema,
1650
1709
  ui: def.ui,
1651
1710
  register(server, opts = {}) {
1652
- const { sendLog, deviceNames, deviceAliases, deviceDirectory: deviceDirectory2 } = opts;
1711
+ const { sendLog, deviceNames, deviceAliases, deviceDirectory: deviceDirectory2, appViews } = opts;
1653
1712
  const multiDevice = !!deviceNames && deviceNames.length > 1;
1654
- const { ui, auto } = effectiveUi(def);
1713
+ const { ui, auto } = appViews === false ? { ui: undefined, auto: false } : effectiveUi(def);
1655
1714
  const selectorNames = multiDevice && deviceNames ? [...new Set([...deviceNames, ...deviceAliases ?? []])] : [];
1656
1715
  const inputSchema = multiDevice ? {
1657
1716
  ...def.inputSchema,
@@ -2816,21 +2875,29 @@ ${fileDetails}` : `Section export '${name}.rsc' created successfully.`;
2816
2875
  }),
2817
2876
  defineTool({
2818
2877
  name: "upload_file",
2819
- title: "Upload File to Device (Simulated)",
2878
+ title: "Upload File to Device",
2820
2879
  annotations: WRITE,
2821
- description: "Accepts a base64-encoded file intended for the device filesystem. NOTE: the current handler" + " only validates the base64 encoding and returns a simulated success \u2014 it does not transfer" + " bytes to the device over SSH. Once a `.backup` file is present on the device apply it with" + " `restore_backup`; for `.rsc` configuration scripts use `import_configuration`.",
2880
+ description: "Transfer a file to the device filesystem over SFTP (the file subsystem RouterOS exposes on its SSH" + " server) \u2014 this actually pushes the bytes, then verifies the file appears in `/file`. Use it to put" + " a file on the router before another tool can use it: a `.rsc` config script (then apply with" + " `import_configuration`), a `.backup` file (then `restore_backup`), or a certificate/key (then" + " `import_certificate`). `filename` is the DESTINATION path on the device \u2014 root by default" + " (e.g. `config.rsc`), or an external-disk path (e.g. `disk1/cert.pem`). `content_base64` is the file's" + " raw bytes, base64-encoded (binary-safe \u2014 works for `.backup`/cert files, not just text). Overwrites" + " any existing file of the same name. NOT available on MAC-Telnet devices (Layer-2 has no file" + " transfer) \u2014 there, have the router pull the file itself with `/tool fetch` from a reachable URL.",
2822
2881
  inputSchema: {
2823
- filename: z6.string(),
2824
- content_base64: z6.string()
2882
+ filename: z6.string().describe("Destination path on the device, e.g. 'config.rsc' or 'disk1/cert.pem'"),
2883
+ content_base64: z6.string().describe("File bytes, base64-encoded (binary-safe)")
2825
2884
  },
2826
2885
  async handler(a, ctx) {
2827
2886
  ctx.info(`Uploading file: filename=${a.filename}`);
2887
+ const data = Buffer.from(a.content_base64, "base64");
2888
+ if (data.length === 0) {
2889
+ return "Nothing to upload: content_base64 is empty or decoded to 0 bytes.";
2890
+ }
2828
2891
  try {
2829
- Buffer.from(a.content_base64, "base64").toString("utf8");
2892
+ await uploadFileToDevice(ctx.device, a.filename, data);
2830
2893
  } catch (e) {
2831
- return `Failed to decode file content: ${e instanceof Error ? e.message : String(e)}`;
2894
+ return `Failed to upload '${a.filename}': ${e instanceof Error ? e.message : String(e)}`;
2895
+ }
2896
+ const verify = await executeMikrotikCommand(`/file print count-only where name="${a.filename}"`, ctx);
2897
+ if (verify.trim() === "0") {
2898
+ return `File '${a.filename}' was transferred (${data.length} bytes) but did not appear in /file under ` + "that exact name \u2014 RouterOS may have stored it at a slightly different path. Run list_files to " + "confirm, then apply it (import_configuration / restore_backup / import_certificate).";
2832
2899
  }
2833
- return `File '${a.filename}' uploaded successfully (simulated).`;
2900
+ return `File '${a.filename}' uploaded successfully (${data.length} bytes) and is now in /file. Apply it: ` + "import_configuration for a `.rsc`, restore_backup for a `.backup`, or import_certificate for a cert.";
2834
2901
  }
2835
2902
  }),
2836
2903
  defineTool({
@@ -4396,10 +4463,17 @@ var changePlanTools = [
4396
4463
  const enabled2 = await safe.enable();
4397
4464
  if (enabled2.startsWith("Error"))
4398
4465
  return enabled2;
4466
+ const APPLY_BUDGET_MS = 90000;
4467
+ const startedAt = Date.now();
4468
+ const overBudget = () => Date.now() - startedAt > APPLY_BUDGET_MS;
4399
4469
  try {
4400
4470
  const before = normalizeExport(await safe.execute("/export terse"));
4401
4471
  const log = [];
4402
4472
  for (const step of plan.steps) {
4473
+ if (overBudget()) {
4474
+ await safe.rollback();
4475
+ 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.";
4476
+ }
4403
4477
  const out = await safe.execute(step.command);
4404
4478
  if (looksLikeError(out)) {
4405
4479
  await safe.rollback();
@@ -24979,7 +25053,7 @@ function selectToolModules(filter = {}, catalog = moduleCatalog) {
24979
25053
  // package.json
24980
25054
  var package_default = {
24981
25055
  name: "@usex/mikrotik-mcp",
24982
- version: "3.28.0",
25056
+ version: "3.30.0",
24983
25057
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
24984
25058
  keywords: [
24985
25059
  "ai",
@@ -25185,6 +25259,7 @@ function createServer(opts = {}) {
25185
25259
  deviceNames: names,
25186
25260
  deviceAliases: deviceLabels(),
25187
25261
  deviceDirectory: deviceDirectory(),
25262
+ appViews: getConfig().mcp.appViews,
25188
25263
  readOnly
25189
25264
  });
25190
25265
  const promptCount = registerPrompts(server);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usex/mikrotik-mcp",
3
- "version": "3.28.0",
3
+ "version": "3.30.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",
@@ -87,7 +87,8 @@
87
87
  "allowedHosts": "",
88
88
  "allowedOrigins": "",
89
89
  "corsOrigins": "",
90
- "toolPageSize": 0
90
+ "toolPageSize": 0,
91
+ "appViews": true
91
92
  },
92
93
  "type": "object",
93
94
  "properties": {
@@ -123,6 +124,10 @@
123
124
  "type": "integer",
124
125
  "minimum": 0,
125
126
  "maximum": 9007199254740991
127
+ },
128
+ "appViews": {
129
+ "default": true,
130
+ "type": "boolean"
126
131
  }
127
132
  },
128
133
  "required": [
@@ -132,7 +137,8 @@
132
137
  "allowedHosts",
133
138
  "allowedOrigins",
134
139
  "corsOrigins",
135
- "toolPageSize"
140
+ "toolPageSize",
141
+ "appViews"
136
142
  ],
137
143
  "additionalProperties": false
138
144
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "version": "3.27.0",
3
+ "version": "3.29.0",
4
4
  "generated": "by scripts/gen-schemas.ts — do not edit by hand",
5
5
  "toolCount": 758,
6
6
  "tools": [
@@ -25552,22 +25552,24 @@
25552
25552
  },
25553
25553
  {
25554
25554
  "name": "upload_file",
25555
- "title": "Upload File to Device (Simulated)",
25555
+ "title": "Upload File to Device",
25556
25556
  "risk": "write",
25557
25557
  "annotations": {
25558
25558
  "destructiveHint": false,
25559
25559
  "openWorldHint": false
25560
25560
  },
25561
- "description": "Accepts a base64-encoded file intended for the device filesystem. NOTE: the current handler only validates the base64 encoding and returns a simulated success it does not transfer bytes to the device over SSH. Once a `.backup` file is present on the device apply it with `restore_backup`; for `.rsc` configuration scripts use `import_configuration`.",
25561
+ "description": "Transfer a file to the device filesystem over SFTP (the file subsystem RouterOS exposes on its SSH server) — this actually pushes the bytes, then verifies the file appears in `/file`. Use it to put a file on the router before another tool can use it: a `.rsc` config script (then apply with `import_configuration`), a `.backup` file (then `restore_backup`), or a certificate/key (then `import_certificate`). `filename` is the DESTINATION path on the device root by default (e.g. `config.rsc`), or an external-disk path (e.g. `disk1/cert.pem`). `content_base64` is the file's raw bytes, base64-encoded (binary-safe — works for `.backup`/cert files, not just text). Overwrites any existing file of the same name. NOT available on MAC-Telnet devices (Layer-2 has no file transfer) — there, have the router pull the file itself with `/tool fetch` from a reachable URL.",
25562
25562
  "inputSchema": {
25563
25563
  "$schema": "https://json-schema.org/draft/2020-12/schema",
25564
25564
  "type": "object",
25565
25565
  "properties": {
25566
25566
  "filename": {
25567
- "type": "string"
25567
+ "type": "string",
25568
+ "description": "Destination path on the device, e.g. 'config.rsc' or 'disk1/cert.pem'"
25568
25569
  },
25569
25570
  "content_base64": {
25570
- "type": "string"
25571
+ "type": "string",
25572
+ "description": "File bytes, base64-encoded (binary-safe)"
25571
25573
  }
25572
25574
  },
25573
25575
  "required": ["filename", "content_base64"],
@@ -4,10 +4,12 @@
4
4
  "type": "object",
5
5
  "properties": {
6
6
  "filename": {
7
- "type": "string"
7
+ "type": "string",
8
+ "description": "Destination path on the device, e.g. 'config.rsc' or 'disk1/cert.pem'"
8
9
  },
9
10
  "content_base64": {
10
- "type": "string"
11
+ "type": "string",
12
+ "description": "File bytes, base64-encoded (binary-safe)"
11
13
  }
12
14
  },
13
15
  "required": ["filename", "content_base64"],