@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 +99 -24
- package/dist/index.d.ts +24 -1
- package/dist/index.js +99 -24
- package/package.json +1 -1
- package/schemas/config.schema.json +8 -2
- package/schemas/tool-catalog.json +7 -5
- package/schemas/tools/upload_file.json +4 -2
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
|
-
|
|
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
|
|
937
|
-
|
|
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
|
|
2865
|
+
title: "Upload File to Device",
|
|
2807
2866
|
annotations: WRITE,
|
|
2808
|
-
description: "
|
|
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
|
-
|
|
2879
|
+
await uploadFileToDevice(ctx.device, a.filename, data);
|
|
2817
2880
|
} catch (e) {
|
|
2818
|
-
return `Failed to
|
|
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 (
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
923
|
-
|
|
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
|
|
2878
|
+
title: "Upload File to Device",
|
|
2820
2879
|
annotations: WRITE,
|
|
2821
|
-
description: "
|
|
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
|
-
|
|
2892
|
+
await uploadFileToDevice(ctx.device, a.filename, data);
|
|
2830
2893
|
} catch (e) {
|
|
2831
|
-
return `Failed to
|
|
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 (
|
|
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.
|
|
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
|
@@ -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.
|
|
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
|
|
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": "
|
|
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"],
|