@usex/mikrotik-mcp 3.26.0 → 3.27.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
@@ -22,7 +22,8 @@ var McpServerSettingsSchema = z.object({
22
22
  port: z.coerce.number().int().positive().default(8000),
23
23
  allowedHosts: z.string().default(""),
24
24
  allowedOrigins: z.string().default(""),
25
- corsOrigins: z.string().default("")
25
+ corsOrigins: z.string().default(""),
26
+ toolPageSize: z.coerce.number().int().min(0).default(0)
26
27
  });
27
28
  var DeviceConfigSchema = z.object({
28
29
  host: z.string().default("127.0.0.1"),
@@ -185,7 +186,8 @@ function loadConfig(argv = process.argv.slice(2)) {
185
186
  port: pick("mcp-port", "MIKROTIK_MCP__PORT"),
186
187
  allowedHosts: pick("mcp-allowed-hosts", "MIKROTIK_MCP__ALLOWED_HOSTS"),
187
188
  allowedOrigins: pick("mcp-allowed-origins", "MIKROTIK_MCP__ALLOWED_ORIGINS"),
188
- corsOrigins: pick("mcp-cors-origins", "MIKROTIK_MCP__CORS_ORIGINS")
189
+ corsOrigins: pick("mcp-cors-origins", "MIKROTIK_MCP__CORS_ORIGINS"),
190
+ toolPageSize: pick("tool-page-size", "MIKROTIK_MCP__TOOL_PAGE_SIZE")
189
191
  };
190
192
  const isTruthy = (v) => /^(1|true|yes|on)$/i.test(v ?? "");
191
193
  const readOnly = isTruthy(pick("read-only", "MIKROTIK_READ_ONLY"));
@@ -269,6 +271,24 @@ function resolveDeviceName(name) {
269
271
  }
270
272
  return active.defaultDevice in active.devices ? active.defaultDevice : Object.keys(active.devices)[0] ?? active.defaultDevice;
271
273
  }
274
+ function deviceTarget(dc) {
275
+ if (!dc)
276
+ return "?";
277
+ return dc.mac ? `MAC ${dc.mac}` : `${dc.host}:${dc.port ?? 22}`;
278
+ }
279
+ function deviceDirectory() {
280
+ return Object.entries(active.devices).map(([key, dc]) => ({
281
+ key,
282
+ label: dc.description?.trim() || undefined,
283
+ target: deviceTarget(dc),
284
+ isDefault: key === active.defaultDevice
285
+ }));
286
+ }
287
+ function resolvedTarget(name) {
288
+ const key = resolveDeviceName(name);
289
+ const dc = active.devices[key];
290
+ return { key, label: dc?.description?.trim() || undefined, target: deviceTarget(dc) };
291
+ }
272
292
  function getDevice(name) {
273
293
  if (name && !(name in active.devices) && !deviceKeyForLabel(name)) {
274
294
  throw new Error(`Unknown device '${name}'. Configured devices: ${Object.keys(active.devices).join(", ")}`);
@@ -1681,6 +1701,16 @@ function effectiveUi(def) {
1681
1701
  }
1682
1702
  return { ui: undefined, auto: false };
1683
1703
  }
1704
+ function deviceSelectorDescription(selectorNames, directory) {
1705
+ if (directory && directory.length > 0) {
1706
+ const rows = directory.map((d) => `\u2022 ${d.key}${d.label && d.label !== d.key ? ` ("${d.label}")` : ""} \u2192 ${d.target}${d.isDefault ? " [default]" : ""}`).join(`
1707
+ `);
1708
+ return "Which configured MikroTik device to run this on. Pass the EXACT config key (or its label) " + "that matches the user's wording \u2014 these are different physical routers, so never substitute " + `one for another (e.g. "Ali Home" is NOT "home"). Configured devices:
1709
+ ` + `${rows}
1710
+ ` + "Omit only when the user did not name a device (uses the default).";
1711
+ }
1712
+ return `Which configured MikroTik device to run this on. One of: ${selectorNames.join(", ")} ` + "(a config key or its label). Omit to use the default device.";
1713
+ }
1684
1714
  var READ = {
1685
1715
  readOnlyHint: true,
1686
1716
  idempotentHint: true,
@@ -1713,19 +1743,25 @@ function defineTool(def) {
1713
1743
  inputSchema: def.inputSchema,
1714
1744
  ui: def.ui,
1715
1745
  register(server, opts = {}) {
1716
- const { sendLog, deviceNames, deviceAliases } = opts;
1746
+ const { sendLog, deviceNames, deviceAliases, deviceDirectory: deviceDirectory2 } = opts;
1717
1747
  const multiDevice = !!deviceNames && deviceNames.length > 1;
1718
1748
  const { ui, auto } = effectiveUi(def);
1719
1749
  const selectorNames = multiDevice && deviceNames ? [...new Set([...deviceNames, ...deviceAliases ?? []])] : [];
1720
1750
  const inputSchema = multiDevice ? {
1721
1751
  ...def.inputSchema,
1722
- device: z2.enum(selectorNames).optional().describe(`Which configured MikroTik device to run this on. One of: ${selectorNames.join(", ")} (a config key or its label). Omit to use the default device.`)
1752
+ device: z2.enum(selectorNames).optional().describe(deviceSelectorDescription(selectorNames, deviceDirectory2))
1723
1753
  } : def.inputSchema;
1724
1754
  const risk = riskOf(def.annotations);
1725
1755
  const callback = async (args) => {
1726
1756
  const { device, ...rest } = args;
1727
1757
  const deviceName = typeof device === "string" ? device : undefined;
1728
1758
  const ctx = createContext(sendLog, deviceName);
1759
+ const deviceStamp = multiDevice && risk !== "READ" ? (() => {
1760
+ const t = resolvedTarget(deviceName);
1761
+ const label = t.label && t.label !== t.key ? ` "${t.label}"` : "";
1762
+ const how = deviceName === undefined ? " \u2014 DEFAULT (no device specified)" : "";
1763
+ return `\u21B3 executed on device: ${t.key}${label}${how} \u2192 ${t.target}`;
1764
+ })() : null;
1729
1765
  const startedAt = Date.now();
1730
1766
  let outText = "";
1731
1767
  let isErr = false;
@@ -1754,6 +1790,8 @@ function defineTool(def) {
1754
1790
  result.structuredContent = out.structuredContent;
1755
1791
  hasStructured = true;
1756
1792
  }
1793
+ if (deviceStamp)
1794
+ result.content.push({ type: "text", text: deviceStamp });
1757
1795
  return result;
1758
1796
  } catch (e) {
1759
1797
  const msg = e instanceof Error ? e.message : String(e);
@@ -1762,7 +1800,10 @@ function defineTool(def) {
1762
1800
  errMsg = msg;
1763
1801
  outText = `Error: ${msg}`;
1764
1802
  return {
1765
- content: [{ type: "text", text: `Error: ${msg}` }],
1803
+ content: [
1804
+ { type: "text", text: `Error: ${msg}` },
1805
+ ...deviceStamp ? [{ type: "text", text: deviceStamp }] : []
1806
+ ],
1766
1807
  isError: true
1767
1808
  };
1768
1809
  } finally {
@@ -3815,19 +3856,28 @@ ${details}` : "Certificate creation completed but unable to verify.";
3815
3856
  name: "sign_certificate",
3816
3857
  title: "Sign Certificate",
3817
3858
  annotations: WRITE,
3818
- description: "Signs an existing certificate template (`/certificate sign`) to produce a valid certificate. " + "Omit `ca` to produce a self-signed certificate; supply the name of an existing CA certificate in `ca` " + "to issue it under that CA. " + "The certificate template to sign must already exist \u2014 create it first with `create_certificate`. " + "Optionally override the common name at signing time via `common_name`. " + "May run for several seconds while the device generates key material. " + "Returns the signing operation output from the device.",
3859
+ description: "Signs an existing certificate template (`/certificate sign`) to produce a valid certificate. " + "Omit `ca` to produce a self-signed certificate; supply the name of an existing CA certificate in `ca` " + "to issue it under that CA. " + "IMPORTANT: certificate stores are PER-DEVICE \u2014 the template (and the `ca`, if given) must live on the " + "SAME router this runs on. A 'CA not found' error almost always means the template or CA was created on a " + "different device; verify with list_certificates on this device first (and check the targeting). " + "Set the common name (CN) when you create the template (create_certificate), not here \u2014 `/certificate sign` " + "has no CN option. " + "May run for several seconds while the device generates key material. " + "Returns the signing operation output from the device.",
3819
3860
  inputSchema: {
3820
- name: z9.string().describe("Name of the certificate to sign"),
3821
- ca: z9.string().optional().describe("Name of the CA certificate to sign with (omit to self-sign)"),
3822
- common_name: z9.string().optional().describe("Override the common name when signing")
3861
+ name: z9.string().describe("Name of the certificate template to sign"),
3862
+ ca: z9.string().optional().describe("Name of the CA certificate to sign with (must exist on THIS device; omit to self-sign)")
3823
3863
  },
3824
3864
  async handler(a, ctx) {
3825
- ctx.info(`Signing certificate: name=${a.name}`);
3826
- const cmd = new Cmd("/certificate sign").raw(a.name).opt("ca", a.ca).opt("common-name", a.common_name).build();
3865
+ ctx.info(`Signing certificate: name=${a.name}${a.ca ? ` ca=${a.ca}` : " (self-signed)"}`);
3866
+ const templateCount = await executeMikrotikCommand(`/certificate print count-only where name="${a.name}"`, ctx);
3867
+ if (templateCount.trim() === "0") {
3868
+ return `Certificate template '${a.name}' was not found on this device. Certificate stores are per-device \u2014 ` + `create the template here first (create_certificate), and make sure you are targeting the router that holds it ` + `(use list_certificates / list_mikrotik_devices to confirm).`;
3869
+ }
3870
+ if (a.ca) {
3871
+ const caCount = await executeMikrotikCommand(`/certificate print count-only where name="${a.ca}"`, ctx);
3872
+ if (caCount.trim() === "0") {
3873
+ return `CA certificate '${a.ca}' was not found on this device. The signing CA must exist on the SAME router as ` + `the template (certificate stores are per-device). Omit 'ca' to self-sign, or create/import the CA on this device first.`;
3874
+ }
3875
+ }
3876
+ const cmd = new Cmd("/certificate sign").raw(a.name).opt("ca", a.ca).build();
3827
3877
  const result = await executeMikrotikCommand(cmd, ctx);
3828
3878
  if (looksLikeError(result))
3829
3879
  return `Failed to sign certificate: ${result}`;
3830
- return `Signing certificate '${a.name}'...
3880
+ return `Signing certificate '${a.name}'${a.ca ? ` with CA '${a.ca}'` : " (self-signed)"}\u2026
3831
3881
 
3832
3882
  ${result}`;
3833
3883
  }
@@ -4698,7 +4748,7 @@ var deviceTools = [
4698
4748
  name: "list_mikrotik_devices",
4699
4749
  title: "List Configured MikroTik Devices",
4700
4750
  annotations: READ,
4701
- description: "List all MikroTik devices registered in this server's configuration (server-side metadata \u2014 no RouterOS command is run, no SSH connection is opened). " + "Use this to discover the exact device name strings required by the `device` argument on every other tool, especially when working across multiple routers (e.g. building a tunnel between two devices). " + "Returns each device's name, username, host, port, auth method (key / password / none), optional description, and which entry is the default. " + "Credentials and private-key material are never included in the output.",
4751
+ description: "List all MikroTik devices registered in this server's configuration (server-side metadata \u2014 no RouterOS command is run, no SSH connection is opened). " + "Use this to discover the exact device name strings required by the `device` argument on every other tool, especially when working across multiple routers (e.g. building a tunnel between two devices). " + "Returns each device's name, username, host, port, auth method (key / password / none), optional description, and which entry is the default. " + "This name \u2192 host:port mapping is FIXED for the life of the server process \u2014 it does NOT change mid-session, so a given device name always reaches the same physical router. " + "Call this to verify the exact target before any write/destructive change when several routers are configured. " + "Credentials and private-key material are never included in the output.",
4702
4752
  handler(_a, ctx) {
4703
4753
  ctx.info("Listing configured MikroTik devices");
4704
4754
  const { names, default: def } = listDevices();
@@ -12048,7 +12098,7 @@ ${details}`;
12048
12098
  },
12049
12099
  async handler(a, ctx) {
12050
12100
  ctx.info(`Creating L2TP client: name=${a.name}, connect_to=${a.connect_to}`);
12051
- const cmd = new Cmd("/interface l2tp-client add").set("name", a.name).set("connect-to", a.connect_to).set("user", a.user).set("password", a.password).opt("profile", a.profile).bool("add-default-route", a.add_default_route).opt("default-route-distance", a.default_route_distance).opt("use-ipsec", a.use_ipsec).opt("ipsec-secret", a.ipsec_secret).opt("allow", a.allow).bool("use-peer-dns", a.use_peer_dns).bool("dial-on-demand", a.dial_on_demand).opt("keepalive-timeout", a.keepalive_timeout).opt("max-mtu", a.max_mtu).opt("max-mru", a.max_mru).opt("mrru", a.mrru).opt("comment", a.comment).flag("disabled", a.disabled).build();
12101
+ const cmd = new Cmd("/interface l2tp-client add").set("name", a.name).set("connect-to", a.connect_to).set("user", a.user).set("password", a.password).opt("profile", a.profile).bool("add-default-route", a.add_default_route).opt("default-route-distance", a.default_route_distance).opt("use-ipsec", a.use_ipsec).opt("ipsec-secret", a.ipsec_secret).opt("allow", a.allow).bool("use-peer-dns", a.use_peer_dns).bool("dial-on-demand", a.dial_on_demand).opt("keepalive-timeout", a.keepalive_timeout).opt("max-mtu", a.max_mtu).opt("max-mru", a.max_mru).opt("mrru", a.mrru).opt("comment", a.comment).bool("disabled", a.disabled).build();
12052
12102
  const result = await executeMikrotikCommand(cmd, ctx);
12053
12103
  if (looksLikeError(result))
12054
12104
  return `Failed to create L2TP client: ${redactSecrets(result)}`;
@@ -14511,7 +14561,7 @@ ${details}`;
14511
14561
  },
14512
14562
  async handler(a, ctx) {
14513
14563
  ctx.info(`Adding OpenVPN server: name=${a.name}`);
14514
- const cmd = new Cmd("/interface ovpn-server server add").set("name", a.name).opt("port", a.port).opt("protocol", a.protocol).opt("mode", a.mode).opt("netmask", a.netmask).opt("mac-address", a.mac_address).opt("certificate", a.certificate).opt("auth", a.auth).opt("cipher", a.cipher).opt("tls-version", a.tls_version).opt("max-mtu", a.max_mtu).opt("default-profile", a.default_profile).bool("require-client-certificate", a.require_client_certificate).opt("comment", a.comment).flag("disabled", a.disabled).build();
14564
+ const cmd = new Cmd("/interface ovpn-server server add").set("name", a.name).opt("port", a.port).opt("protocol", a.protocol).opt("mode", a.mode).opt("netmask", a.netmask).opt("mac-address", a.mac_address).opt("certificate", a.certificate).opt("auth", a.auth).opt("cipher", a.cipher).opt("tls-version", a.tls_version).opt("max-mtu", a.max_mtu).opt("default-profile", a.default_profile).bool("require-client-certificate", a.require_client_certificate).opt("comment", a.comment).bool("disabled", a.disabled).build();
14515
14565
  const result = await executeMikrotikCommand(cmd, ctx);
14516
14566
  if (containsRawParserError(result)) {
14517
14567
  return "Failed to add OpenVPN server: this RouterOS build does not support named OpenVPN" + " server instances (the multi-server model requires RouterOS 7.17+). Use set_ovpn_server" + ` to configure the single legacy server instead.
@@ -14664,7 +14714,7 @@ ${details}`;
14664
14714
  },
14665
14715
  async handler(a, ctx) {
14666
14716
  ctx.info(`Creating OpenVPN client: name=${a.name}, connect_to=${a.connect_to}`);
14667
- const cmd = new Cmd("/interface ovpn-client add").set("name", a.name).set("connect-to", a.connect_to).opt("port", a.port).opt("user", a.user).opt("password", a.password).opt("certificate", a.certificate).opt("cipher", a.cipher).opt("auth", a.auth).opt("tls-version", a.tls_version).opt("mode", a.mode).opt("protocol", a.protocol).opt("mac-address", a.mac_address).opt("max-mtu", a.max_mtu).opt("profile", a.profile).bool("add-default-route", a.add_default_route).bool("route-nopull", a.route_nopull).bool("use-peer-dns", a.use_peer_dns).bool("verify-server-certificate", a.verify_server_certificate).opt("comment", a.comment).flag("disabled", a.disabled).build();
14717
+ const cmd = new Cmd("/interface ovpn-client add").set("name", a.name).set("connect-to", a.connect_to).opt("port", a.port).opt("user", a.user).opt("password", a.password).opt("certificate", a.certificate).opt("cipher", a.cipher).opt("auth", a.auth).opt("tls-version", a.tls_version).opt("mode", a.mode).opt("protocol", a.protocol).opt("mac-address", a.mac_address).opt("max-mtu", a.max_mtu).opt("profile", a.profile).bool("add-default-route", a.add_default_route).bool("route-nopull", a.route_nopull).bool("use-peer-dns", a.use_peer_dns).bool("verify-server-certificate", a.verify_server_certificate).opt("comment", a.comment).bool("disabled", a.disabled).build();
14668
14718
  const result = await executeMikrotikCommand(cmd, ctx);
14669
14719
  if (looksLikeError(result))
14670
14720
  return `Failed to create OpenVPN client: ${redactSecrets(result)}`;
@@ -15203,7 +15253,7 @@ ${details}`;
15203
15253
  },
15204
15254
  async handler(a, ctx) {
15205
15255
  ctx.info(`Creating PPTP client: name=${a.name}, connect_to=${a.connect_to}`);
15206
- const cmd = new Cmd("/interface pptp-client add").set("name", a.name).set("connect-to", a.connect_to).set("user", a.user).set("password", a.password).opt("profile", a.profile).bool("add-default-route", a.add_default_route).opt("default-route-distance", a.default_route_distance).opt("allow", a.allow).bool("dial-on-demand", a.dial_on_demand).opt("max-mtu", a.max_mtu).opt("max-mru", a.max_mru).opt("mrru", a.mrru).opt("keepalive-timeout", a.keepalive_timeout).opt("comment", a.comment).flag("disabled", a.disabled).build();
15256
+ const cmd = new Cmd("/interface pptp-client add").set("name", a.name).set("connect-to", a.connect_to).set("user", a.user).set("password", a.password).opt("profile", a.profile).bool("add-default-route", a.add_default_route).opt("default-route-distance", a.default_route_distance).opt("allow", a.allow).bool("dial-on-demand", a.dial_on_demand).opt("max-mtu", a.max_mtu).opt("max-mru", a.max_mru).opt("mrru", a.mrru).opt("keepalive-timeout", a.keepalive_timeout).opt("comment", a.comment).bool("disabled", a.disabled).build();
15207
15257
  const result = await executeMikrotikCommand(cmd, ctx);
15208
15258
  if (looksLikeError(result))
15209
15259
  return `Failed to create PPTP client: ${redactSecrets(result)}`;
@@ -19773,7 +19823,7 @@ ${details}`;
19773
19823
  async handler(a, ctx) {
19774
19824
  ctx.info(`Creating SSTP client: name=${a.name}, connect_to=${a.connect_to}`);
19775
19825
  const { host, port } = splitHostPort(a.connect_to);
19776
- const cmd = new Cmd("/interface sstp-client add").set("name", a.name).set("connect-to", host).opt("port", a.port ?? port).set("user", a.user).set("password", a.password).opt("profile", a.profile).opt("certificate", a.certificate).bool("verify-server-certificate", a.verify_server_certificate).opt("authentication", a.authentication).opt("tls-version", a.tls_version).bool("pfs", a.pfs).bool("add-default-route", a.add_default_route).opt("default-route-distance", a.default_route_distance).bool("dial-on-demand", a.dial_on_demand).opt("max-mtu", a.max_mtu).opt("max-mru", a.max_mru).opt("mrru", a.mrru).opt("keepalive-timeout", a.keepalive_timeout).opt("http-proxy", a.http_proxy).opt("comment", a.comment).flag("disabled", a.disabled).build();
19826
+ const cmd = new Cmd("/interface sstp-client add").set("name", a.name).set("connect-to", host).opt("port", a.port ?? port).set("user", a.user).set("password", a.password).opt("profile", a.profile).opt("certificate", a.certificate).bool("verify-server-certificate", a.verify_server_certificate).opt("authentication", a.authentication).opt("tls-version", a.tls_version).bool("pfs", a.pfs).bool("add-default-route", a.add_default_route).opt("default-route-distance", a.default_route_distance).bool("dial-on-demand", a.dial_on_demand).opt("max-mtu", a.max_mtu).opt("max-mru", a.max_mru).opt("mrru", a.mrru).opt("keepalive-timeout", a.keepalive_timeout).opt("http-proxy", a.http_proxy).opt("comment", a.comment).bool("disabled", a.disabled).build();
19777
19827
  const result = await executeMikrotikCommand(cmd, ctx);
19778
19828
  if (looksLikeError(result))
19779
19829
  return `Failed to create SSTP client: ${redactSecrets(result)}`;
@@ -19820,7 +19870,7 @@ ${redactSecrets(result)}`;
19820
19870
  name: "remove_sstp_client",
19821
19871
  title: "Remove SSTP Client Interface",
19822
19872
  annotations: DESTRUCTIVE,
19823
- description: "Permanently delete an SSTP client tunnel interface (`/interface sstp-client remove [find name=...]`) by interface name. " + "First verifies the interface exists (count-only check), then removes it; the tunnel is torn down immediately and the action is irreversible. " + "Use `list_sstp_clients` to confirm the interface name before calling this tool. " + "For L2TP, OpenVPN, or PPTP client interfaces see their respective tool scopes (`create_l2tp_client`, `create_ovpn_client`, `create_pptp_client`). " + "To disable the interface without deleting it, no dedicated enable/disable tool exists in this scope \u2014 set `disabled=yes` via RouterOS directly. " + "Returns a confirmation message on success or a not-found message if the name does not exist.",
19873
+ description: "Permanently delete an SSTP client tunnel interface (`/interface sstp-client remove [find name=...]`) by interface name. " + "First verifies the interface exists (count-only check), then removes it; the tunnel is torn down immediately and the action is irreversible. " + "Use `list_sstp_clients` to confirm the interface name before calling this tool. " + "For L2TP, OpenVPN, or PPTP client interfaces see their respective tool scopes (`create_l2tp_client`, `create_ovpn_client`, `create_pptp_client`). " + "To deactivate the interface without deleting it use `disable_sstp_client` (re-activate with `enable_sstp_client`). " + "Returns a confirmation message on success or a not-found message if the name does not exist.",
19824
19874
  inputSchema: { name: z97.string() },
19825
19875
  async handler(a, ctx) {
19826
19876
  ctx.info(`Removing SSTP client: name=${a.name}`);
@@ -19832,6 +19882,40 @@ ${redactSecrets(result)}`;
19832
19882
  return `Failed to remove SSTP client: ${result}`;
19833
19883
  return `SSTP client '${a.name}' removed successfully.`;
19834
19884
  }
19885
+ }),
19886
+ defineTool({
19887
+ name: "enable_sstp_client",
19888
+ title: "Enable SSTP Client Interface",
19889
+ annotations: WRITE_IDEMPOTENT,
19890
+ description: "Enables a disabled SSTP client interface (`/interface sstp-client enable [find name=...]`), " + "causing the router to dial out and attempt the TLS connection to the remote SSTP server. " + "Use to activate a tunnel that was created disabled or stopped with `disable_sstp_client`. " + "For L2TP/PPTP/OpenVPN tunnels use their own enable tools. " + "Identifies the interface by name \u2014 create one with `create_sstp_client`.",
19891
+ inputSchema: { name: z97.string() },
19892
+ async handler(a, ctx) {
19893
+ ctx.info(`Enabling SSTP client: name=${a.name}`);
19894
+ const count = await executeMikrotikCommand(`/interface sstp-client print count-only where name="${a.name}"`, ctx);
19895
+ if (count.trim() === "0")
19896
+ return `SSTP client '${a.name}' not found.`;
19897
+ const result = await executeMikrotikCommand(`/interface sstp-client enable [find name="${a.name}"]`, ctx);
19898
+ if (looksLikeError(result))
19899
+ return `Failed to enable SSTP client: ${result}`;
19900
+ return `SSTP client '${a.name}' enabled successfully.`;
19901
+ }
19902
+ }),
19903
+ defineTool({
19904
+ name: "disable_sstp_client",
19905
+ title: "Disable SSTP Client Interface",
19906
+ annotations: WRITE_IDEMPOTENT,
19907
+ description: "Disables an active SSTP client interface (`/interface sstp-client disable [find name=...]`), " + "tearing down the tunnel without removing its configuration. " + "Use to temporarily stop a tunnel while preserving its settings for later reuse. " + "To re-enable use `enable_sstp_client`; to permanently remove use `remove_sstp_client`.",
19908
+ inputSchema: { name: z97.string() },
19909
+ async handler(a, ctx) {
19910
+ ctx.info(`Disabling SSTP client: name=${a.name}`);
19911
+ const count = await executeMikrotikCommand(`/interface sstp-client print count-only where name="${a.name}"`, ctx);
19912
+ if (count.trim() === "0")
19913
+ return `SSTP client '${a.name}' not found.`;
19914
+ const result = await executeMikrotikCommand(`/interface sstp-client disable [find name="${a.name}"]`, ctx);
19915
+ if (looksLikeError(result))
19916
+ return `Failed to disable SSTP client: ${result}`;
19917
+ return `SSTP client '${a.name}' disabled successfully.`;
19918
+ }
19835
19919
  })
19836
19920
  ];
19837
19921
 
@@ -25108,8 +25192,18 @@ async function openUsageStore(path) {
25108
25192
  // src/observability/usage-sampler.ts
25109
25193
  var SERVER_TAG = "mikrotik-mcp";
25110
25194
  var USAGE_RETENTION_MS = 93 * 24 * 60 * 60 * 1000;
25195
+ var DEFAULT_USAGE_INTERVAL_MS = 60000;
25196
+ var MIN_USAGE_INTERVAL_MS = 30000;
25197
+ var MAX_USAGE_INTERVAL_MS = 6 * 60 * 60000;
25111
25198
  var timer2 = null;
25112
25199
  var inFlight2 = false;
25200
+ var currentStore = null;
25201
+ var currentIntervalMs = DEFAULT_USAGE_INTERVAL_MS;
25202
+ function clampInterval(ms) {
25203
+ if (!Number.isFinite(ms))
25204
+ return DEFAULT_USAGE_INTERVAL_MS;
25205
+ return Math.max(MIN_USAGE_INTERVAL_MS, Math.min(MAX_USAGE_INTERVAL_MS, Math.round(ms)));
25206
+ }
25113
25207
  function bytesOf(v) {
25114
25208
  return parseSize(v) ?? parseLeadingNumber(v) ?? 0;
25115
25209
  }
@@ -25177,15 +25271,31 @@ async function sampleUsageOnce(store2) {
25177
25271
  inFlight2 = false;
25178
25272
  }
25179
25273
  }
25180
- function startUsageSampler(store2, intervalMs = 10 * 60000) {
25274
+ function startUsageSampler(store2, intervalMs = DEFAULT_USAGE_INTERVAL_MS) {
25275
+ currentStore = store2;
25276
+ currentIntervalMs = clampInterval(intervalMs);
25181
25277
  sampleUsageOnce(store2);
25182
- timer2 = setInterval(() => void sampleUsageOnce(store2), intervalMs);
25278
+ timer2 = setInterval(() => void sampleUsageOnce(store2), currentIntervalMs);
25279
+ }
25280
+ function getUsageSamplerInterval() {
25281
+ return currentIntervalMs;
25282
+ }
25283
+ function setUsageSamplerInterval(intervalMs) {
25284
+ currentIntervalMs = clampInterval(intervalMs);
25285
+ if (timer2)
25286
+ clearInterval(timer2);
25287
+ if (currentStore) {
25288
+ const store2 = currentStore;
25289
+ timer2 = setInterval(() => void sampleUsageOnce(store2), currentIntervalMs);
25290
+ }
25291
+ return currentIntervalMs;
25183
25292
  }
25184
25293
  function stopUsageSampler() {
25185
25294
  if (timer2) {
25186
25295
  clearInterval(timer2);
25187
25296
  timer2 = null;
25188
25297
  }
25298
+ currentStore = null;
25189
25299
  }
25190
25300
 
25191
25301
  // src/observability/stats.ts
@@ -25746,10 +25856,20 @@ function daysParam(url, fallback, max) {
25746
25856
  const n = Number(url.searchParams.get("days"));
25747
25857
  return Number.isFinite(n) && n > 0 ? Math.min(n, max) : fallback;
25748
25858
  }
25749
- function usageRoutes(req, url) {
25859
+ async function usageRoutes(req, url) {
25750
25860
  const p = url.pathname;
25751
25861
  if (!p.startsWith("/api/usage"))
25752
25862
  return null;
25863
+ if (p === "/api/usage/sampler") {
25864
+ if (req.method === "GET")
25865
+ return json({ intervalMs: getUsageSamplerInterval() });
25866
+ if (req.method === "POST") {
25867
+ const b = await readJson(req);
25868
+ const applied = setUsageSamplerInterval(Number(b?.intervalMs));
25869
+ return json({ intervalMs: applied });
25870
+ }
25871
+ return null;
25872
+ }
25753
25873
  if (req.method !== "GET")
25754
25874
  return null;
25755
25875
  if (!usageStore)
@@ -26075,7 +26195,7 @@ async function runDashboard(cfg, transportLabel) {
26075
26195
  const aaaResp = await aaaRoutes(req, url);
26076
26196
  if (aaaResp)
26077
26197
  return aaaResp;
26078
- const usageResp = usageRoutes(req, url);
26198
+ const usageResp = await usageRoutes(req, url);
26079
26199
  if (usageResp)
26080
26200
  return usageResp;
26081
26201
  const featureResp = await featureRoutes(req, url);
@@ -26219,6 +26339,7 @@ function corsHeaders(origin, configured) {
26219
26339
 
26220
26340
  // src/server.ts
26221
26341
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
26342
+ import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
26222
26343
 
26223
26344
  // src/prompts/index.ts
26224
26345
  import { readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
@@ -26325,7 +26446,7 @@ function registerPrompts(server) {
26325
26446
  // package.json
26326
26447
  var package_default = {
26327
26448
  name: "@usex/mikrotik-mcp",
26328
- version: "3.26.0",
26449
+ version: "3.27.0",
26329
26450
  description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
26330
26451
  keywords: [
26331
26452
  "ai",
@@ -26530,12 +26651,37 @@ function createServer(opts = {}) {
26530
26651
  sendLog,
26531
26652
  deviceNames: names,
26532
26653
  deviceAliases: deviceLabels(),
26654
+ deviceDirectory: deviceDirectory(),
26533
26655
  readOnly
26534
26656
  });
26535
26657
  const promptCount = registerPrompts(server);
26536
26658
  const uiViewCount = registerUiResources(server);
26659
+ installToolPagination(server, getConfig().mcp.toolPageSize);
26537
26660
  return { server, toolCount, promptCount, uiViewCount, readOnly };
26538
26661
  }
26662
+ function installToolPagination(server, pageSize) {
26663
+ if (pageSize <= 0)
26664
+ return;
26665
+ const low = server.server;
26666
+ const sdkHandler = low._requestHandlers?.get("tools/list");
26667
+ if (typeof sdkHandler !== "function")
26668
+ return;
26669
+ let cache = null;
26670
+ server.server.setRequestHandler(ListToolsRequestSchema, async (request, extra) => {
26671
+ if (!cache)
26672
+ cache = (await sdkHandler(request, extra)).tools ?? [];
26673
+ const total = cache.length;
26674
+ const cursor = request.params?.cursor;
26675
+ let start = 0;
26676
+ if (typeof cursor === "string") {
26677
+ const n = Number.parseInt(cursor, 10);
26678
+ start = Number.isFinite(n) && n > 0 ? Math.min(n, total) : 0;
26679
+ }
26680
+ const tools = cache.slice(start, start + pageSize);
26681
+ const end = start + tools.length;
26682
+ return end < total ? { tools, nextCursor: String(end) } : { tools };
26683
+ });
26684
+ }
26539
26685
 
26540
26686
  // src/transport/http.ts
26541
26687
  var LOCALHOST = new Set(["127.0.0.1", "localhost", "::1", "0.0.0.0"]);
package/dist/index.d.ts CHANGED
@@ -46,6 +46,35 @@ interface UiLink {
46
46
  */
47
47
  visibility?: ("model" | "app")[];
48
48
  }
49
+ declare function setConfig(cfg: MikrotikConfig): void;
50
+ declare function getConfig(): MikrotikConfig;
51
+ /** Names of every configured device, plus which one is the default. */
52
+ declare function listDevices(): {
53
+ names: string[];
54
+ default: string;
55
+ };
56
+ /**
57
+ * Resolve a (possibly undefined) device name to a concrete, existing config key.
58
+ * Accepts a config key OR a device's free-text label; falls back to the default.
59
+ *
60
+ * Matching is EXACT (key first, then label) — never fuzzy/substring — so a name
61
+ * like "Ali Home" can never collapse onto a different device such as "home".
62
+ */
63
+ declare function resolveDeviceName(name?: string): string;
64
+ /** One row of the human-facing device directory shown in the `device` selector. */
65
+ interface DeviceDirectoryEntry {
66
+ key: string;
67
+ label?: string;
68
+ /** Where it connects: `host:port`, or `MAC <addr>` for a MAC-Telnet device. */
69
+ target: string;
70
+ isDefault: boolean;
71
+ }
72
+ /**
73
+ * Return the connection config for a device by key or label (or the default when
74
+ * `name` is undefined). Throws if an explicit name matches neither a key nor a
75
+ * label.
76
+ */
77
+ declare function getDevice(name?: string): DeviceConfig;
49
78
  /** Options threaded into every tool registration. */
50
79
  interface RegisterOptions {
51
80
  sendLog?: SendLog;
@@ -59,6 +88,12 @@ interface RegisterOptions {
59
88
  */
60
89
  deviceAliases?: string[];
61
90
  /**
91
+ * Human-facing directory of every device (key → label → target), used to make
92
+ * the `device` selector's description unambiguous so the model can tell
93
+ * similarly-named routers apart and never substitute one for another.
94
+ */
95
+ deviceDirectory?: DeviceDirectoryEntry[];
96
+ /**
62
97
  * Read-only mode: register only tools annotated `readOnlyHint`. Used to
63
98
  * withhold every write/destructive tool from a publicly-exposed surface (e.g.
64
99
  * a ChatGPT Apps connector) until authentication is in place.
@@ -110,24 +145,6 @@ declare function defineTool<Shape extends ZodRawShape>(def: ToolDef<Shape>): Reg
110
145
  type ToolModule = RegisterableTool[];
111
146
  /** Register every tool from every module, returning the total count. */
112
147
  declare function registerTools(server: McpServer, modules: ToolModule[], opts?: RegisterOptions): number;
113
- declare function setConfig(cfg: MikrotikConfig): void;
114
- declare function getConfig(): MikrotikConfig;
115
- /** Names of every configured device, plus which one is the default. */
116
- declare function listDevices(): {
117
- names: string[];
118
- default: string;
119
- };
120
- /**
121
- * Resolve a (possibly undefined) device name to a concrete, existing config key.
122
- * Accepts a config key OR a device's free-text label; falls back to the default.
123
- */
124
- declare function resolveDeviceName(name?: string): string;
125
- /**
126
- * Return the connection config for a device by key or label (or the default when
127
- * `name` is undefined). Throws if an explicit name matches neither a key nor a
128
- * label.
129
- */
130
- declare function getDevice(name?: string): DeviceConfig;
131
148
  import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
132
149
  interface CreatedServer {
133
150
  server: McpServer2;