@usex/mikrotik-mcp 2.1.0 → 2.3.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/index.js CHANGED
@@ -23,6 +23,10 @@ var DeviceConfigSchema = z.object({
23
23
  privateKey: z.string().optional(),
24
24
  keyPassphrase: z.string().optional(),
25
25
  timeoutMs: z.coerce.number().int().positive().default(1e4),
26
+ mac: z.string().optional(),
27
+ sourceMac: z.string().optional(),
28
+ macHost: z.string().optional(),
29
+ macPort: z.coerce.number().int().positive().optional(),
26
30
  description: z.string().optional()
27
31
  });
28
32
  var S3ConfigSchema = z.object({
@@ -110,7 +114,11 @@ function loadConfig(argv = process.argv.slice(2)) {
110
114
  keyFilename: pick("key-filename", "MIKROTIK_KEY_FILENAME"),
111
115
  privateKey: pick("private-key", "MIKROTIK_PRIVATE_KEY"),
112
116
  keyPassphrase: pick("key-passphrase", "MIKROTIK_KEY_PASSPHRASE"),
113
- timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS")
117
+ timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS"),
118
+ mac: pick("mac", "MIKROTIK_MAC"),
119
+ sourceMac: pick("source-mac", "MIKROTIK_SOURCE_MAC"),
120
+ macHost: pick("mac-host", "MIKROTIK_MAC_HOST"),
121
+ macPort: pick("mac-port", "MIKROTIK_MAC_PORT")
114
122
  };
115
123
  const hasSingle = Object.values(single).some((v) => v !== undefined);
116
124
  const devices = {};
@@ -211,10 +219,6 @@ function getDevice(name) {
211
219
  return dc;
212
220
  }
213
221
 
214
- // src/ssh/client.ts
215
- import { readFileSync as readFileSync2 } from "fs";
216
- import { Client } from "ssh2";
217
-
218
222
  // src/logger.ts
219
223
  import { stderr } from "process";
220
224
  var LEVELS = {
@@ -247,7 +251,343 @@ var logger = {
247
251
  error: (m) => emit("error", m)
248
252
  };
249
253
 
254
+ // src/mac-telnet/console.ts
255
+ import { MacTelnetSession } from "@tikoci/centrs/protocols";
256
+ var ESC = "\x1B";
257
+ var enc = new TextEncoder;
258
+ var ANSI_CSI = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g");
259
+ var ANSI_ESC2 = new RegExp(`${ESC}[@-_]`, "g");
260
+ var ROUTEROS_PROMPT_RE = /\[[^\]@\r\n]+@[^\]\r\n]*\][^\r\n]*>\s*$/;
261
+ var TICK_INTERVAL_MS = 15;
262
+ var LICENSE_RE = /do you want to see the software license/i;
263
+ function emulateScreen(text) {
264
+ const clean = text.replace(ANSI_CSI, "").replace(ANSI_ESC2, "");
265
+ const lines = [[]];
266
+ let row = 0;
267
+ let col = 0;
268
+ for (const ch of clean) {
269
+ const code = ch.charCodeAt(0);
270
+ if (ch === `
271
+ `) {
272
+ row += 1;
273
+ if (!lines[row])
274
+ lines[row] = [];
275
+ col = 0;
276
+ } else if (ch === "\r") {
277
+ col = 0;
278
+ } else if (code === 8) {
279
+ col = Math.max(0, col - 1);
280
+ } else if (code >= 32 && code !== 127) {
281
+ const line = lines[row];
282
+ line[col] = ch;
283
+ col += 1;
284
+ }
285
+ }
286
+ return lines.map((line) => Array.from(line, (c) => c ?? " ").join("").replace(/\s+$/, ""));
287
+ }
288
+ function extractCommandOutput(raw, command) {
289
+ const lines = emulateScreen(raw);
290
+ let start = 1;
291
+ if (command !== undefined && lines.length > 0) {
292
+ const first = lines[0] ?? "";
293
+ const promptMatch = first.match(/^\[[^\]\r\n]*\][^\r\n]*?>\s?/);
294
+ const echoedOnFirst = promptMatch ? first.slice(promptMatch[0].length) : first;
295
+ let consumed = echoedOnFirst.length;
296
+ while (consumed < command.length && start < lines.length) {
297
+ consumed += (lines[start] ?? "").length;
298
+ start += 1;
299
+ }
300
+ }
301
+ const body = lines.slice(start);
302
+ while (body.length > 0) {
303
+ const last = body[body.length - 1];
304
+ if (last.length === 0 || ROUTEROS_PROMPT_RE.test(last)) {
305
+ body.pop();
306
+ continue;
307
+ }
308
+ break;
309
+ }
310
+ return body.join(`
311
+ `);
312
+ }
313
+
314
+ class MacTelnetConsole {
315
+ options;
316
+ session;
317
+ buffer = "";
318
+ ready = false;
319
+ closed = false;
320
+ closeError;
321
+ waiter;
322
+ tickTimer;
323
+ readyWaiters = [];
324
+ decoder = new TextDecoder;
325
+ probeTail = "";
326
+ constructor(options) {
327
+ this.options = {
328
+ rows: 9999,
329
+ cols: 512,
330
+ primeTimeoutMs: 30000,
331
+ commandTimeoutMs: 15000,
332
+ settleMs: 150,
333
+ acceptLicense: true,
334
+ ...options
335
+ };
336
+ this.session = new MacTelnetSession({
337
+ sink: options.sink,
338
+ sourceMac: options.sourceMac,
339
+ destinationMac: options.destinationMac,
340
+ username: options.username,
341
+ password: options.password,
342
+ sessionKey: options.sessionKey,
343
+ terminalType: "vt102",
344
+ terminalWidth: this.options.cols,
345
+ terminalHeight: this.options.rows,
346
+ onReady: () => this.onReady(),
347
+ onData: (bytes) => this.onData(bytes),
348
+ onClose: (error) => this.onClose(error)
349
+ });
350
+ }
351
+ handlePacket(bytes) {
352
+ try {
353
+ this.session.handlePacket(bytes);
354
+ } catch (error) {
355
+ this.onClose(error instanceof Error ? error : new Error("Failed to process a MAC-Telnet datagram."));
356
+ }
357
+ }
358
+ async open() {
359
+ this.tickTimer = setInterval(() => {
360
+ try {
361
+ this.session.tick(Date.now());
362
+ } catch {}
363
+ }, TICK_INTERVAL_MS);
364
+ this.tickTimer.unref?.();
365
+ this.session.start();
366
+ await this.waitReady(this.options.primeTimeoutMs);
367
+ await this.waitFor((buffer) => this.endsWithPrompt(buffer) || LICENSE_RE.test(buffer), this.options.primeTimeoutMs, "waiting for the RouterOS console prompt");
368
+ if (this.options.acceptLicense && LICENSE_RE.test(this.buffer)) {
369
+ this.buffer = "";
370
+ this.session.sendInput(enc.encode("n\r"));
371
+ await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.primeTimeoutMs, "waiting for the prompt after the license screen");
372
+ }
373
+ this.buffer = "";
374
+ this.session.sendInput(enc.encode("\r"));
375
+ await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.commandTimeoutMs, "waiting for a clean prompt");
376
+ this.buffer = "";
377
+ }
378
+ async run(cli) {
379
+ this.assertOpen();
380
+ this.buffer = "";
381
+ this.session.sendInput(enc.encode(`${cli}\r`));
382
+ await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.commandTimeoutMs, `running over mac-telnet: ${cli}`);
383
+ const raw = this.buffer;
384
+ return { output: extractCommandOutput(raw, cli), raw };
385
+ }
386
+ close() {
387
+ if (this.tickTimer) {
388
+ clearInterval(this.tickTimer);
389
+ this.tickTimer = undefined;
390
+ }
391
+ if (!this.closed)
392
+ this.session.end();
393
+ }
394
+ get isReady() {
395
+ return this.ready && !this.closed;
396
+ }
397
+ onReady() {
398
+ this.ready = true;
399
+ for (const w of this.readyWaiters.splice(0))
400
+ w.resolve();
401
+ }
402
+ onData(bytes) {
403
+ const chunk = this.decoder.decode(bytes, { stream: true });
404
+ const combined = `${this.probeTail}${chunk}`;
405
+ this.answerSizeProbe(combined);
406
+ this.probeTail = combined.slice(-3);
407
+ this.buffer += chunk;
408
+ this.checkWaiter();
409
+ }
410
+ onClose(error) {
411
+ this.closed = true;
412
+ this.closeError = error;
413
+ if (this.tickTimer) {
414
+ clearInterval(this.tickTimer);
415
+ this.tickTimer = undefined;
416
+ }
417
+ const failure = error ?? new Error("The MAC-Telnet console session closed.");
418
+ for (const w of this.readyWaiters.splice(0))
419
+ w.reject(failure);
420
+ if (this.waiter) {
421
+ const waiter = this.waiter;
422
+ this.waiter = undefined;
423
+ clearTimeout(waiter.timeout);
424
+ if (waiter.settle)
425
+ clearTimeout(waiter.settle);
426
+ waiter.reject(failure);
427
+ }
428
+ }
429
+ answerSizeProbe(chunk) {
430
+ if (chunk.includes(`${ESC}[6n`)) {
431
+ this.session.sendInput(enc.encode(`${ESC}[${this.options.rows};${this.options.cols}R`));
432
+ }
433
+ if (chunk.includes(`${ESC}Z`) || chunk.includes(`${ESC}[c`)) {
434
+ this.session.sendInput(enc.encode(`${ESC}[?6c`));
435
+ }
436
+ }
437
+ endsWithPrompt(buffer) {
438
+ const lines = emulateScreen(buffer).filter((line) => line.length > 0);
439
+ return ROUTEROS_PROMPT_RE.test(lines[lines.length - 1] ?? "");
440
+ }
441
+ waitReady(timeoutMs) {
442
+ if (this.ready)
443
+ return Promise.resolve();
444
+ if (this.closed) {
445
+ return Promise.reject(this.closeError ?? new Error("The MAC-Telnet session closed before login completed."));
446
+ }
447
+ return new Promise((resolve, reject) => {
448
+ let timer;
449
+ const entry = {
450
+ resolve: () => {
451
+ clearTimeout(timer);
452
+ resolve();
453
+ },
454
+ reject: (error) => {
455
+ clearTimeout(timer);
456
+ reject(error);
457
+ }
458
+ };
459
+ timer = setTimeout(() => {
460
+ this.readyWaiters = this.readyWaiters.filter((w) => w !== entry);
461
+ reject(new Error("MAC-Telnet login did not complete \u2014 no console response from the device. " + "Confirm the device is reachable over mac-telnet (mac-server interface list) and the credentials are correct."));
462
+ }, timeoutMs);
463
+ this.readyWaiters.push(entry);
464
+ });
465
+ }
466
+ waitFor(predicate, timeoutMs, label) {
467
+ if (this.closed) {
468
+ return Promise.reject(this.closeError ?? new Error(`MAC-Telnet session closed while ${label}.`));
469
+ }
470
+ return new Promise((resolve, reject) => {
471
+ const timeout = setTimeout(() => {
472
+ this.waiter = undefined;
473
+ reject(new Error(`Timed out ${label}. The RouterOS console did not return a prompt in time; ` + "raise the timeout or confirm the device is responsive over mac-telnet."));
474
+ }, timeoutMs);
475
+ this.waiter = { predicate, resolve, reject, timeout };
476
+ this.checkWaiter();
477
+ });
478
+ }
479
+ checkWaiter() {
480
+ const waiter = this.waiter;
481
+ if (!waiter)
482
+ return;
483
+ if (!waiter.predicate(this.buffer)) {
484
+ if (waiter.settle) {
485
+ clearTimeout(waiter.settle);
486
+ waiter.settle = undefined;
487
+ }
488
+ return;
489
+ }
490
+ if (waiter.settle)
491
+ clearTimeout(waiter.settle);
492
+ waiter.settle = setTimeout(() => {
493
+ if (this.waiter !== waiter)
494
+ return;
495
+ this.waiter = undefined;
496
+ clearTimeout(waiter.timeout);
497
+ waiter.resolve();
498
+ }, this.options.settleMs);
499
+ }
500
+ assertOpen() {
501
+ if (!this.ready || this.closed) {
502
+ throw new Error("The MAC-Telnet console is not open. Call open() and await it before running commands.");
503
+ }
504
+ }
505
+ }
506
+
507
+ // src/mac-telnet/client.ts
508
+ import {
509
+ DEFAULT_MAC_TELNET_BROADCAST,
510
+ isBroadcastHost,
511
+ MAC_TELNET_PORT,
512
+ createUdpMacTelnetTransport,
513
+ parseMac,
514
+ resolveMacTelnetRoute
515
+ } from "@tikoci/centrs/protocols";
516
+
517
+ class MikroTikMacTelnetClient {
518
+ transport = null;
519
+ console = null;
520
+ opts;
521
+ lastError;
522
+ constructor(opts) {
523
+ this.opts = {
524
+ port: MAC_TELNET_PORT,
525
+ timeoutMs: 1e4,
526
+ ...opts
527
+ };
528
+ }
529
+ async connect() {
530
+ this.lastError = undefined;
531
+ try {
532
+ const destinationMac = parseMac(this.opts.mac);
533
+ const explicitSourceMac = this.opts.sourceMac ? parseMac(this.opts.sourceMac) : undefined;
534
+ const host = this.opts.host ?? DEFAULT_MAC_TELNET_BROADCAST;
535
+ const route = await resolveMacTelnetRoute({
536
+ destinationMac,
537
+ host,
538
+ port: this.opts.port,
539
+ timeoutMs: this.opts.timeoutMs,
540
+ explicitSourceMac
541
+ });
542
+ const transport = createUdpMacTelnetTransport({
543
+ host: route.host,
544
+ port: this.opts.port,
545
+ broadcast: isBroadcastHost(route.host)
546
+ });
547
+ this.transport = transport;
548
+ await transport.ready();
549
+ const console = new MacTelnetConsole({
550
+ sink: transport,
551
+ sourceMac: route.sourceMac,
552
+ destinationMac,
553
+ username: this.opts.username,
554
+ password: this.opts.password ?? "",
555
+ primeTimeoutMs: Math.max(this.opts.timeoutMs, 30000),
556
+ commandTimeoutMs: Math.max(this.opts.timeoutMs, 15000)
557
+ });
558
+ this.console = console;
559
+ transport.onMessage((bytes) => console.handlePacket(bytes));
560
+ await console.open();
561
+ return true;
562
+ } catch (e) {
563
+ this.lastError = e instanceof Error ? e.message : String(e);
564
+ logger.error(`Failed to connect to MikroTik over MAC-Telnet: ${this.lastError}`);
565
+ this.disconnect();
566
+ return false;
567
+ }
568
+ }
569
+ async run(command) {
570
+ if (!this.console || !this.console.isReady) {
571
+ throw new Error("Not connected to MikroTik device (MAC-Telnet)");
572
+ }
573
+ const { output } = await this.console.run(command);
574
+ return output;
575
+ }
576
+ disconnect() {
577
+ try {
578
+ this.console?.close();
579
+ } catch {}
580
+ try {
581
+ this.transport?.close();
582
+ } catch {}
583
+ this.console = null;
584
+ this.transport = null;
585
+ }
586
+ }
587
+
250
588
  // src/ssh/client.ts
589
+ import { readFileSync as readFileSync2 } from "fs";
590
+ import { Client } from "ssh2";
251
591
  function decodeOutput(data) {
252
592
  if (!data || data.length === 0)
253
593
  return "";
@@ -347,6 +687,42 @@ class MikroTikSSHClient {
347
687
  }
348
688
  }
349
689
 
690
+ // src/core/transport.ts
691
+ function isMacTelnetDevice(dc) {
692
+ return Boolean(dc.mac);
693
+ }
694
+ function createDeviceClient(dc) {
695
+ if (isMacTelnetDevice(dc)) {
696
+ return new MikroTikMacTelnetClient({
697
+ mac: dc.mac,
698
+ username: dc.username,
699
+ password: dc.password,
700
+ sourceMac: dc.sourceMac,
701
+ host: dc.macHost,
702
+ port: dc.macPort,
703
+ timeoutMs: dc.timeoutMs
704
+ });
705
+ }
706
+ return new MikroTikSSHClient({
707
+ host: dc.host,
708
+ username: dc.username,
709
+ password: dc.password,
710
+ keyFilename: dc.keyFilename,
711
+ privateKey: dc.privateKey,
712
+ keyPassphrase: dc.keyPassphrase,
713
+ port: dc.port,
714
+ timeoutMs: dc.timeoutMs
715
+ });
716
+ }
717
+ function connectErrorMessage(name, dc, lastError) {
718
+ const reason = lastError ? ` \u2014 ${lastError}` : "";
719
+ if (dc.mac) {
720
+ return `Failed to connect to MikroTik device '${name}' over MAC-Telnet at ${dc.mac}${reason}. ` + "Check you are on the same Layer-2 segment, MAC-Telnet is enabled (/tool mac-server) on a reachable interface, and the credentials are correct.";
721
+ }
722
+ const authMode = dc.keyFilename || dc.privateKey ? "SSH key" : dc.password ? "password" : "no credentials";
723
+ return `Failed to connect to MikroTik device '${name}' at ${dc.host}:${dc.port} (auth: ${authMode})${reason}. ` + "Check the host/port are reachable, the SSH service is enabled (/ip service), and the credentials are correct.";
724
+ }
725
+
350
726
  // src/ssh/safe-mode.ts
351
727
  var PROMPT_RE = /\[.+?@.+?\] (?:<SAFE> )?> ?$/m;
352
728
  var ANSI_RE = /\x1B(?:\[[0-9;]*[mA-HJ-MSTfhilnprsu]|[()][0-9A-Za-z]|\[?\?\d+[hl])/g;
@@ -381,6 +757,9 @@ class SafeModeManager {
381
757
  if (this.active)
382
758
  return "Safe mode is already active.";
383
759
  const dc = getDevice(this.deviceName);
760
+ if (dc.mac) {
761
+ return "Error: Safe Mode is not supported for a MAC-Telnet device " + `('${this.deviceName}' is reached by MAC ${dc.mac}). ` + "Connect over SSH (configure host/credentials) to use Safe Mode.";
762
+ }
384
763
  const ssh = new MikroTikSSHClient({
385
764
  host: dc.host,
386
765
  username: dc.username,
@@ -515,25 +894,14 @@ function getSafeModeManager(deviceName) {
515
894
  async function runOnce(command, deviceName) {
516
895
  const name = resolveDeviceName(deviceName);
517
896
  const dc = getDevice(deviceName);
518
- const ssh = new MikroTikSSHClient({
519
- host: dc.host,
520
- username: dc.username,
521
- password: dc.password,
522
- keyFilename: dc.keyFilename,
523
- privateKey: dc.privateKey,
524
- keyPassphrase: dc.keyPassphrase,
525
- port: dc.port,
526
- timeoutMs: dc.timeoutMs
527
- });
897
+ const client = createDeviceClient(dc);
528
898
  try {
529
- if (!await ssh.connect()) {
530
- const authMode = dc.keyFilename || dc.privateKey ? "SSH key" : dc.password ? "password" : "no credentials";
531
- const reason = ssh.lastError ? ` \u2014 ${ssh.lastError}` : "";
532
- throw new Error(`Failed to connect to MikroTik device '${name}' at ${dc.host}:${dc.port} (auth: ${authMode})${reason}. ` + "Check the host/port are reachable, the SSH service is enabled (/ip service), and the credentials are correct.");
899
+ if (!await client.connect()) {
900
+ throw new Error(connectErrorMessage(name, dc, client.lastError));
533
901
  }
534
- return await ssh.run(command);
902
+ return await client.run(command);
535
903
  } finally {
536
- ssh.disconnect();
904
+ client.disconnect();
537
905
  }
538
906
  }
539
907
  async function executeMikrotikCommand(command, ctx) {
@@ -17343,7 +17711,7 @@ var allToolModules = moduleCatalog.map((m) => m.tools);
17343
17711
  // package.json
17344
17712
  var package_default = {
17345
17713
  name: "@usex/mikrotik-mcp",
17346
- version: "2.1.0",
17714
+ version: "2.3.0",
17347
17715
  description: "Bun-native MCP server for MikroTik RouterOS \u2014 200+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
17348
17716
  keywords: [
17349
17717
  "ai",
@@ -17417,6 +17785,7 @@ var package_default = {
17417
17785
  dependencies: {
17418
17786
  "@modelcontextprotocol/ext-apps": "^1.7.4",
17419
17787
  "@modelcontextprotocol/sdk": "^1.29.0",
17788
+ "@tikoci/centrs": "^0.1.0",
17420
17789
  ssh2: "^1.17.0",
17421
17790
  zod: "^4.4.3"
17422
17791
  },