mikromcp 1.10.0 → 1.11.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/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  [![CI](https://github.com/AliKarami/MikroMCP/actions/workflows/ci.yml/badge.svg)](https://github.com/AliKarami/MikroMCP/actions/workflows/ci.yml)
14
14
  [![Release](https://github.com/AliKarami/MikroMCP/actions/workflows/release.yml/badge.svg)](https://github.com/AliKarami/MikroMCP/actions/workflows/release.yml)
15
- [![Version](https://img.shields.io/badge/version-v1.10.0-2DD4BF)](package.json)
15
+ [![Version](https://img.shields.io/badge/version-v1.11.0-2DD4BF)](package.json)
16
16
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
17
17
  [![Node.js >= 22](https://img.shields.io/badge/node-%3E%3D22-339933)](package.json)
18
18
  [![RouterOS 7.x](https://img.shields.io/badge/RouterOS-7.x-293239)](https://help.mikrotik.com/docs/display/ROS/REST+API)
@@ -61,6 +61,8 @@ routers:
61
61
  tags: ["datacenter", "core"]
62
62
  rosVersion: "7.14"
63
63
  # sshPort: 22 # SSH port (default 22)
64
+ # sshUsername: "automation" # Optional SSH/SFTP username; defaults to REST username
65
+ # sshPrivateKeyPath: "/home/user/.ssh/id_ed25519" # Absolute path; suppresses SSH/SFTP password auth
64
66
  # sshFingerprint: "aabbcc..." # Pin SSH host key SHA256 fingerprint (hex)
65
67
  # cmdAllow: # Strict allow-list for run_command (see above)
66
68
  # - "/ip/route/print*"
package/dist/main.js CHANGED
@@ -1705,13 +1705,51 @@ var firewallTools = [listFirewallRulesTool, manageFirewallRuleTool];
1705
1705
 
1706
1706
  // src/domain/tools/diagnostic-tools.ts
1707
1707
  import { z as z9 } from "zod";
1708
+
1709
+ // src/adapter/routeros-command.ts
1710
+ var ROUTEROS_COMMAND = /^\/[a-z][a-z0-9-]*(?: [a-z][a-z0-9-]*)+$/;
1711
+ var ROUTEROS_PARAMETER = /^[a-z][a-z0-9-]*$/;
1712
+ var CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
1713
+ function isRouterOsCommandString(value) {
1714
+ return !CONTROL_CHARACTER.test(value);
1715
+ }
1716
+ function quoteRouterOsString(value) {
1717
+ if (!isRouterOsCommandString(value)) {
1718
+ throw new Error("RouterOS command values must not contain control characters");
1719
+ }
1720
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$");
1721
+ return `"${escaped}"`;
1722
+ }
1723
+ function buildRouterOsCommand(command, parameters) {
1724
+ if (!ROUTEROS_COMMAND.test(command)) {
1725
+ throw new Error("Invalid RouterOS command");
1726
+ }
1727
+ const parts = [command];
1728
+ for (const [name, value] of Object.entries(parameters)) {
1729
+ if (!ROUTEROS_PARAMETER.test(name)) {
1730
+ throw new Error("Invalid RouterOS parameter");
1731
+ }
1732
+ if (typeof value === "number") {
1733
+ if (!Number.isSafeInteger(value)) {
1734
+ throw new Error("RouterOS numeric command values must be safe integers");
1735
+ }
1736
+ parts.push(`${name}=${value}`);
1737
+ } else {
1738
+ parts.push(`${name}=${quoteRouterOsString(value)}`);
1739
+ }
1740
+ }
1741
+ return parts.join(" ");
1742
+ }
1743
+
1744
+ // src/domain/tools/diagnostic-tools.ts
1708
1745
  var log7 = createLogger("diagnostic-tools");
1746
+ var routerOsCommandString = z9.string().refine(isRouterOsCommandString, "Must not contain control characters");
1709
1747
  var pingInputSchema = z9.object({
1710
1748
  routerId,
1711
- address: z9.string().describe("Target IP address or hostname to ping"),
1749
+ address: routerOsCommandString.describe("Target IP address or hostname to ping"),
1712
1750
  count: z9.number().int().min(1).max(20).default(4).describe("Number of ICMP echo requests (1\u201320)"),
1713
1751
  size: z9.number().int().min(14).max(65535).default(56).describe("Packet size in bytes (14\u201365535)"),
1714
- routingTable: z9.string().optional().describe("Routing table to use for the ping")
1752
+ routingTable: routerOsCommandString.optional().describe("Routing table to use for the ping")
1715
1753
  }).strict();
1716
1754
  var pingTool = {
1717
1755
  name: "ping",
@@ -1728,13 +1766,15 @@ var pingTool = {
1728
1766
  const parsed = pingInputSchema.parse(params);
1729
1767
  log7.info({ routerId: context.routerId, address: parsed.address }, "Pinging");
1730
1768
  try {
1731
- const parts = [
1732
- `/tool ping address=${parsed.address}`,
1733
- `count=${parsed.count}`,
1734
- `size=${parsed.size}`
1735
- ];
1736
- if (parsed.routingTable !== void 0) parts.push(`routing-table=${parsed.routingTable}`);
1737
- const output = await context.sshClient.execute(parts.join(" "));
1769
+ const parameters = {
1770
+ address: parsed.address,
1771
+ count: parsed.count,
1772
+ size: parsed.size
1773
+ };
1774
+ if (parsed.routingTable !== void 0) parameters["routing-table"] = parsed.routingTable;
1775
+ const output = await context.sshClient.execute(
1776
+ buildRouterOsCommand("/tool ping", parameters)
1777
+ );
1738
1778
  const s = output.match(/sent=(\d+)/)?.[1] ?? String(parsed.count);
1739
1779
  const r = output.match(/received=(\d+)/)?.[1] ?? "0";
1740
1780
  const loss = output.match(/packet-loss=(\S+)/)?.[1] ?? "100%";
@@ -1762,7 +1802,7 @@ var pingTool = {
1762
1802
  };
1763
1803
  var tracerouteInputSchema = z9.object({
1764
1804
  routerId,
1765
- address: z9.string().describe("Target IP address or hostname to trace"),
1805
+ address: routerOsCommandString.describe("Target IP address or hostname to trace"),
1766
1806
  count: z9.number().int().min(1).max(5).default(3).describe("Probes per hop (1\u20135)"),
1767
1807
  maxHops: z9.number().int().min(1).max(30).default(15).describe("Maximum number of hops (1\u201330)")
1768
1808
  }).strict();
@@ -1781,12 +1821,13 @@ var tracerouteTool = {
1781
1821
  const parsed = tracerouteInputSchema.parse(params);
1782
1822
  log7.info({ routerId: context.routerId, address: parsed.address }, "Tracerouting");
1783
1823
  try {
1784
- const parts = [
1785
- `/tool traceroute address=${parsed.address}`,
1786
- `count=${parsed.count}`,
1787
- `max-hops=${parsed.maxHops}`
1788
- ];
1789
- const output = await context.sshClient.execute(parts.join(" "));
1824
+ const output = await context.sshClient.execute(
1825
+ buildRouterOsCommand("/tool traceroute", {
1826
+ address: parsed.address,
1827
+ count: parsed.count,
1828
+ "max-hops": parsed.maxHops
1829
+ })
1830
+ );
1790
1831
  const hopLines = output.split("\n").filter((l) => /^\s*\d+\s+\S+/.test(l));
1791
1832
  const hops = hopLines.map((line) => {
1792
1833
  const cols = line.trim().split(/\s+/);
@@ -1817,10 +1858,10 @@ var tracerouteTool = {
1817
1858
  };
1818
1859
  var torchInputSchema = z9.object({
1819
1860
  routerId,
1820
- interface: z9.string().describe("Interface name to monitor (e.g. ether1, bridge1)"),
1861
+ interface: routerOsCommandString.describe("Interface name to monitor (e.g. ether1, bridge1)"),
1821
1862
  duration: z9.number().int().min(1).max(30).default(5).describe("Capture duration in seconds (1\u201330)"),
1822
- srcAddress: z9.string().optional().describe("Filter by source IP address"),
1823
- dstAddress: z9.string().optional().describe("Filter by destination IP address")
1863
+ srcAddress: routerOsCommandString.optional().describe("Filter by source IP address"),
1864
+ dstAddress: routerOsCommandString.optional().describe("Filter by destination IP address")
1824
1865
  }).strict();
1825
1866
  function parseTorchOutput(output) {
1826
1867
  const flows = [];
@@ -1853,10 +1894,13 @@ var torchTool = {
1853
1894
  const parsed = torchInputSchema.parse(params);
1854
1895
  log7.info({ routerId: context.routerId, interface: parsed.interface }, "Running torch");
1855
1896
  try {
1856
- const parts = [`/tool torch interface=${parsed.interface}`];
1857
- if (parsed.srcAddress !== void 0) parts.push(`src-address=${parsed.srcAddress}`);
1858
- if (parsed.dstAddress !== void 0) parts.push(`dst-address=${parsed.dstAddress}`);
1859
- const raw = await context.sshClient.execute(parts.join(" "), (parsed.duration + 1) * 1e3);
1897
+ const parameters = { interface: parsed.interface };
1898
+ if (parsed.srcAddress !== void 0) parameters["src-address"] = parsed.srcAddress;
1899
+ if (parsed.dstAddress !== void 0) parameters["dst-address"] = parsed.dstAddress;
1900
+ const raw = await context.sshClient.execute(
1901
+ buildRouterOsCommand("/tool torch", parameters),
1902
+ (parsed.duration + 1) * 1e3
1903
+ );
1860
1904
  const clean = raw.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1861
1905
  const flows = parseTorchOutput(clean);
1862
1906
  const lines = [
@@ -5134,7 +5178,7 @@ var uploadFileInputSchema = z22.object({
5134
5178
  routerId,
5135
5179
  name: z22.string().describe("Target filename on the router (e.g. flash/my-script.rsc)"),
5136
5180
  content: z22.string().describe("File content to upload (text)"),
5137
- dryRun: z22.boolean().default(false).describe("Validate FTP connectivity without writing the file")
5181
+ dryRun: z22.boolean().default(false).describe("Validate SFTP or FTP connectivity without writing the target file")
5138
5182
  }).strict();
5139
5183
  var uploadFileTool = {
5140
5184
  name: "upload_file",
@@ -13289,6 +13333,7 @@ function getCredentials(router) {
13289
13333
 
13290
13334
  // src/adapter/ssh-client.ts
13291
13335
  import { createHash as createHash2 } from "crypto";
13336
+ import { readFileSync as readFileSync3 } from "fs";
13292
13337
  import { Client } from "ssh2";
13293
13338
  var SshClient = class {
13294
13339
  constructor(config, credentials, options) {
@@ -13304,6 +13349,7 @@ var SshClient = class {
13304
13349
  async execute(command, overrideTimeoutMs) {
13305
13350
  const timeoutMs = overrideTimeoutMs ?? this.commandTimeoutMs;
13306
13351
  const maxOutputBytes = this.maxOutputBytes;
13352
+ const privateKey = this.config.sshPrivateKeyPath ? readFileSync3(this.config.sshPrivateKeyPath) : void 0;
13307
13353
  return new Promise((resolve, reject) => {
13308
13354
  const conn = new Client();
13309
13355
  const chunks = [];
@@ -13369,10 +13415,11 @@ var SshClient = class {
13369
13415
  const connectOptions = {
13370
13416
  host: this.config.host,
13371
13417
  port: this.config.sshPort ?? 22,
13372
- username: this.credentials.username,
13373
- password: this.credentials.password,
13418
+ username: this.config.sshUsername ?? this.credentials.username,
13374
13419
  readyTimeout: 1e4
13375
13420
  };
13421
+ if (privateKey) connectOptions.privateKey = privateKey;
13422
+ else connectOptions.password = this.credentials.password;
13376
13423
  if (this.config.sshFingerprint) {
13377
13424
  const expected = this.config.sshFingerprint.toLowerCase();
13378
13425
  connectOptions.hostVerifier = (key2) => {
@@ -13434,6 +13481,7 @@ var FtpClient = class {
13434
13481
 
13435
13482
  // src/adapter/sftp-client.ts
13436
13483
  import { createHash as createHash3 } from "crypto";
13484
+ import { readFileSync as readFileSync4 } from "fs";
13437
13485
  import { Client as Client3 } from "ssh2";
13438
13486
  var SftpClient = class {
13439
13487
  constructor(config, credentials) {
@@ -13443,6 +13491,7 @@ var SftpClient = class {
13443
13491
  config;
13444
13492
  credentials;
13445
13493
  async upload(remoteName, content) {
13494
+ const privateKey = this.config.sshPrivateKeyPath ? readFileSync4(this.config.sshPrivateKeyPath) : void 0;
13446
13495
  return new Promise((resolve, reject) => {
13447
13496
  const conn = new Client3();
13448
13497
  let settled = false;
@@ -13468,10 +13517,11 @@ var SftpClient = class {
13468
13517
  const connectOptions = {
13469
13518
  host: this.config.host,
13470
13519
  port: this.config.sshPort ?? 22,
13471
- username: this.credentials.username,
13472
- password: this.credentials.password,
13520
+ username: this.config.sshUsername ?? this.credentials.username,
13473
13521
  readyTimeout: 1e4
13474
13522
  };
13523
+ if (privateKey) connectOptions.privateKey = privateKey;
13524
+ else connectOptions.password = this.credentials.password;
13475
13525
  if (this.config.sshFingerprint) {
13476
13526
  const expected = this.config.sshFingerprint.toLowerCase();
13477
13527
  connectOptions.hostVerifier = (key2) => {
@@ -14754,7 +14804,8 @@ function registerAllTools(server, registry, pool, circuitBreakers, config, ident
14754
14804
  }
14755
14805
 
14756
14806
  // src/config/router-registry.ts
14757
- import { readFileSync as readFileSync3, existsSync } from "fs";
14807
+ import { readFileSync as readFileSync5, existsSync } from "fs";
14808
+ import { isAbsolute } from "path";
14758
14809
  import { parse } from "yaml";
14759
14810
  import { z as z49 } from "zod";
14760
14811
  var log51 = createLogger("router-registry");
@@ -14781,6 +14832,8 @@ var RouterConfigSchema = z49.object({
14781
14832
  tags: z49.array(z49.string()).default([]),
14782
14833
  rosVersion: z49.string().min(1).optional(),
14783
14834
  sshPort: z49.number().int().min(1).max(65535).optional(),
14835
+ sshUsername: z49.string().min(1).optional(),
14836
+ sshPrivateKeyPath: z49.string().min(1).refine(isAbsolute, "sshPrivateKeyPath must be absolute").optional(),
14784
14837
  sshFingerprint: z49.string().optional(),
14785
14838
  cmdAllow: z49.array(z49.string()).optional(),
14786
14839
  cmdDeny: z49.array(z49.string()).optional(),
@@ -14832,7 +14885,7 @@ var RouterRegistry = class {
14832
14885
  log51.warn({ configPath }, "Router config file not found; starting with empty registry");
14833
14886
  return;
14834
14887
  }
14835
- const raw = readFileSync3(configPath, "utf-8");
14888
+ const raw = readFileSync5(configPath, "utf-8");
14836
14889
  const parsed = parse(raw);
14837
14890
  const result = ConfigFileSchema.safeParse(parsed);
14838
14891
  if (!result.success) {
@@ -14960,7 +15013,7 @@ var ConnectionPool = class {
14960
15013
  };
14961
15014
 
14962
15015
  // src/version.ts
14963
- var VERSION = "1.10.0";
15016
+ var VERSION = "1.11.0";
14964
15017
 
14965
15018
  // src/mcp/server.ts
14966
15019
  var SERVER_INSTRUCTIONS = [
@@ -15267,7 +15320,7 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
15267
15320
  }
15268
15321
 
15269
15322
  // src/config/identity-registry.ts
15270
- import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
15323
+ import { readFileSync as readFileSync6, existsSync as existsSync2 } from "fs";
15271
15324
  import { createHash as createHash7 } from "crypto";
15272
15325
  import { parse as parse2 } from "yaml";
15273
15326
  import { z as z50 } from "zod";
@@ -15295,7 +15348,7 @@ var IdentityRegistry = class {
15295
15348
  );
15296
15349
  return;
15297
15350
  }
15298
- const raw = readFileSync4(configPath, "utf-8");
15351
+ const raw = readFileSync6(configPath, "utf-8");
15299
15352
  const parsed = parse2(raw);
15300
15353
  const result = ConfigFileSchema2.safeParse(parsed);
15301
15354
  if (!result.success) {
@@ -15434,11 +15487,11 @@ async function runServe() {
15434
15487
  }
15435
15488
 
15436
15489
  // src/cli/doctor.ts
15437
- import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
15490
+ import { existsSync as existsSync3, readFileSync as readFileSync7 } from "fs";
15438
15491
  import { homedir as homedir2 } from "os";
15439
15492
  import * as net from "net";
15440
15493
  import chalk from "chalk";
15441
- var localVersion = "1.10.0";
15494
+ var localVersion = "1.11.0";
15442
15495
  var results = [];
15443
15496
  function ok(msg) {
15444
15497
  console.log(chalk.green("\u2705 ") + msg);
@@ -15651,7 +15704,7 @@ function checkClaudeDesktop() {
15651
15704
  return;
15652
15705
  }
15653
15706
  try {
15654
- const raw = readFileSync5(configPath, "utf-8");
15707
+ const raw = readFileSync7(configPath, "utf-8");
15655
15708
  const config = JSON.parse(raw);
15656
15709
  const servers = config.mcpServers ?? {};
15657
15710
  if ("mikromcp" in servers || "mikrotik-mcp-server" in servers) {
@@ -15750,7 +15803,7 @@ async function runDoctor() {
15750
15803
  // src/cli/init.ts
15751
15804
  import { input, confirm, select } from "@inquirer/prompts";
15752
15805
  import { randomBytes as randomBytes2 } from "crypto";
15753
- import { existsSync as existsSync4, readFileSync as readFileSync6, writeFileSync, mkdirSync, copyFileSync } from "fs";
15806
+ import { existsSync as existsSync4, readFileSync as readFileSync8, writeFileSync, mkdirSync, copyFileSync } from "fs";
15754
15807
  import { homedir as homedir3 } from "os";
15755
15808
  import { join as join4 } from "path";
15756
15809
  import { parse as yamlParse, Document, visit } from "yaml";
@@ -15974,7 +16027,7 @@ function writeRoutersYaml(data) {
15974
16027
  };
15975
16028
  let parsed = { routers: {} };
15976
16029
  if (existsSync4(filePath)) {
15977
- const raw = readFileSync6(filePath, "utf-8");
16030
+ const raw = readFileSync8(filePath, "utf-8");
15978
16031
  parsed = yamlParse(raw) ?? { routers: {} };
15979
16032
  if (!parsed.routers) parsed.routers = {};
15980
16033
  }
@@ -15993,7 +16046,7 @@ function writeIdentitiesYaml(data) {
15993
16046
  };
15994
16047
  let parsed = { identities: {} };
15995
16048
  if (existsSync4(filePath)) {
15996
- const raw = readFileSync6(filePath, "utf-8");
16049
+ const raw = readFileSync8(filePath, "utf-8");
15997
16050
  parsed = yamlParse(raw) ?? { identities: {} };
15998
16051
  if (!parsed.identities) parsed.identities = {};
15999
16052
  }
@@ -16059,7 +16112,7 @@ function registerClaudeDesktop() {
16059
16112
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
16060
16113
  const backupPath = `${configPath}.backup-${timestamp}`;
16061
16114
  copyFileSync(configPath, backupPath);
16062
- const raw = readFileSync6(configPath, "utf-8");
16115
+ const raw = readFileSync8(configPath, "utf-8");
16063
16116
  const config = JSON.parse(raw);
16064
16117
  if (!config.mcpServers || typeof config.mcpServers !== "object") {
16065
16118
  config.mcpServers = {};
@@ -16168,7 +16221,7 @@ async function runInit() {
16168
16221
  }
16169
16222
 
16170
16223
  // src/cli/index.ts
16171
- var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.10.0");
16224
+ var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.11.0");
16172
16225
  program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
16173
16226
  program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
16174
16227
  program.command("init").description("Interactive setup wizard").action(runInit);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikromcp",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "mcpName": "io.github.AliKarami/mikromcp",
5
5
  "description": "Production-grade MCP server for MikroTik RouterOS",
6
6
  "type": "module",