@coffeerunhobby/mcp-ha-connect 2.5.3 → 2.5.4-rc.1

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
@@ -86,11 +86,11 @@ high-value questions an LLM can actually answer about your network:
86
86
  > above through two tools — `omada_browse` + `omada_read` — keeping the tool-schema budget
87
87
  > small for low-context models. See the Omada tool tables below for the full eager surface.
88
88
 
89
- ## Available Tools (112 Total)
89
+ ## Available Tools (115 Total)
90
90
 
91
91
  With every plugin on in the default eager mode: Home Assistant 38 (including the 3 owner-question tools, when
92
- `MCP_OWNER_QUESTIONS_ENABLED` is on), Omada 49, `net_` 23, AI 1 and `invokeAction` 1 (only with `MCP_REST_ACTIONS`).
93
- In graph mode the Omada reads collapse into `omada_browse` + `omada_read` (Omada 26, 89 in total).
92
+ `MCP_OWNER_QUESTIONS_ENABLED` is on), Omada 49, `net_` 26, AI 1 and `invokeAction` 1 (only with `MCP_REST_ACTIONS`).
93
+ In graph mode the Omada reads collapse into `omada_browse` + `omada_read` (Omada 26, 92 in total).
94
94
 
95
95
  ### Home Assistant Tools (38)
96
96
 
@@ -264,10 +264,10 @@ Lets the controller enforce a schedule itself (e.g. a curfew) instead of a daily
264
264
  | `omada_getSiteNtpStatus` | Get the site's NTP server configuration and status |
265
265
  | `omada_setSiteNtpServers` | Replace the site's NTP server list (needs `CONFIGURE`; `dryRun` previews the change) |
266
266
 
267
- ### Vendor-neutral Network Tools (23)
267
+ ### Vendor-neutral Network Tools (26)
268
268
 
269
269
  The `net_` tools describe networks the same way for every controller vendor: one shared model, with an adapter per
270
- vendor (TP-Link Omada, and Ruijie / Reyee routers read-only since 2.5.0; UniFi and MikroTik are planned). Each adapter
270
+ vendor (TP-Link Omada, and Ruijie / Reyee routers since 2.5.0; UniFi and MikroTik are planned). Each adapter
271
271
  says per feature whether it is native, built from several objects, or unsupported (`net_capabilities`), and refuses what
272
272
  it can't do instead of dropping it. With several controllers configured, every `net_` tool takes an optional
273
273
  `controller` (`omada` or `ruijie`); without it Omada answers.
@@ -294,6 +294,8 @@ one is set in the controller's web UI. Reads need `QUERY`, changes `CONFIGURE`.
294
294
  | `net_getMdnsRepeater` / `net_setMdnsRepeater` | Whether mDNS (Bonjour) discovery is repeated between networks; switch it on or off, or change the networks (e.g. so Home Assistant discovers IoT devices). Discovery only: traffic still follows the rules |
295
295
  | `net_listFirmware` | The firmware each network device runs and, when the vendor offers one, the newer version with its release notes and whether `net_upgradeFirmware` can install it |
296
296
  | `net_upgradeFirmware` | **ADMIN.** Install the version the vendor offers for a device, only from the vendor's own download server; the device restarts keeping its settings, so everything behind it is offline for a few minutes (always needs a dry run first, then `allowLockout` and its `basis`). Ruijie routers only for now; on Omada firmware is listed, upgrades stay in the controller web UI |
297
+ | `net_listReservations` | DHCP reservations: which device (MAC) always gets which address, on which network |
298
+ | `net_setReservation` / `net_deleteReservation` | Reserve an address for a device (create, or change its reservation) or remove its reservation. The address must be a free host address in a routed network (the one given, or the one holding it): not the gateway, network or broadcast address, not reserved for or used right now by another device. An online device moved to another address loses its connections at its next DHCP renewal, so that needs `allowLockout`; a removed reservation changes nothing until the lease runs out. On Omada the reservations of the controller and this server (`MCP_SERVER_ADDRESSES`) are never moved or removed (pinning them to the address they have is fine), and their addresses are never reserved for another device; while where either is can't be told (address unresolvable, or `MCP_SERVER_ADDRESSES` not set), no existing reservation is moved or removed. A description is kept on Omada; Ruijie keeps none (at most 300 reservations there) |
297
299
 
298
300
  On Omada, a network isolation flag (use a block rule instead), a "guest" network type, switching IPv6 on, and WPA2/WPA3
299
301
  mixed mode are not available through the API and are refused. Rules are gateway ACLs (the gateway routes between the
@@ -305,13 +307,14 @@ usually set none, and a port can't be put back on its profile's). The mDNS repea
305
307
  one gateway mDNS rule: create it once in the web UI (with the services to repeat), then change it here; access point
306
308
  mDNS rules, several rules or a one-way rule are shown as `foreign` and left to the web UI.
307
309
 
308
- On a Ruijie / Reyee router (2.5.0, read-only, mapped on a RG-EG105G-V3 with ReyeeOS 2.324) the `net_` list tools show
310
+ On a Ruijie / Reyee router (2.5.0, mapped on a RG-EG105G-V3 with ReyeeOS 2.324) the `net_` list tools show
309
311
  its LAN networks, the devices it sees on its LAN right now (named by their address lease), DHCP reservations, its
310
312
  ports (the WAN port as the protected uplink) and its access-control rules; rules the model can't say faithfully (a
311
313
  source "anywhere", source ports, IPv6, user groups, schedules, only some connection states, an interface restriction the
312
314
  model can't carry, ports for TCP or UDP only) are shown as `foreign`.
313
- Every change but a firmware upgrade (2.5.1: the version the router itself is offered, the same as its page's Online
314
- Upgrade) is refused for now: reservations and rules come next, then networks. It talks to the router's local web
315
+ It changes DHCP reservations (2.5.4, the same calls as its page) and the firmware (2.5.1: the version the router itself
316
+ is offered, the same as its page's Online Upgrade); every other change is refused for now: rules come next, then
317
+ networks. It talks to the router's local web
315
318
  API with its admin password; use `https://` with `RUIJIE_CERT_SHA256` (the router's own login page hides the password
316
319
  with a key that is shipped in the page, so only HTTPS really protects it, and the router's self-signed certificate is
317
320
  best pinned: the RG-EG105G-V3 ships OpenSSL's sample identity, valid until 2122, likely the same on every unit). A refused login is never retried, because wrong passwords lock
@@ -86,6 +86,9 @@ export declare class OmadaNetworkController implements NetworkController {
86
86
  */
87
87
  private buildPortAssign;
88
88
  private buildMdnsSet;
89
+ private buildReservation;
90
+ /** The device's reservation as the controller has it now (undefined: none; 'unread': the read failed). */
91
+ private readBack;
89
92
  plan(change: Change): Promise<Plan>;
90
93
  apply(plan: Plan, options?: {
91
94
  allowLockout?: boolean;
@@ -23,6 +23,7 @@ import { inSubnet, ipToNumber } from '../../utils/ipv4.js';
23
23
  import { redactSecrets } from '../../utils/redact.js';
24
24
  import { NetError, } from '../model.js';
25
25
  import { MAX_NETWORK_NAME, MAX_WIFI_NAME, checkBands, checkDhcp, checkGateway, checkName, checkNetworkUnique, checkVlan } from '../validate.js';
26
+ import { checkReservationDelete, checkReservationSet } from '../reservations.js';
26
27
  import { GROUP_TYPE, controllerPath, controllerRisk, draftAsAcl, draftRule, isOwnedGroup, newRuleKey, ownedGroupName, resolve, ruleKey, tagged, toRule, } from './omadaRules.js';
27
28
  import { MDNS_GATEWAY, TAGS, cutOff, describeNetworks, linkPorts, toMdns, toPortNetworks, toPortProfile } from './omadaPorts.js';
28
29
  const PSEUDO_WLAN_ID = 'gateway';
@@ -299,10 +300,7 @@ export class OmadaNetworkController {
299
300
  return (await this.snapshot()).clients;
300
301
  }
301
302
  async listReservations() {
302
- const rows = await this.client.accessControl.listDhcpReservations(this.options.siteId);
303
- return rows
304
- .filter((r) => typeof r.ip === 'string')
305
- .map((r) => ({ mac: formatMac(r.mac), ip: String(r.ip), network: { id: r.netId }, ...(r.description ? { description: String(r.description) } : {}) }));
303
+ return toReservations(await this.client.accessControl.listDhcpReservations(this.options.siteId));
306
304
  }
307
305
  async listPortProfiles() {
308
306
  return (await this.client.networkConfig.listPortProfiles(this.options.siteId)).map(toPortProfile);
@@ -608,6 +606,107 @@ export class OmadaNetworkController {
608
606
  },
609
607
  };
610
608
  }
609
+ // ---- DHCP reservations (the checks every vendor shares are in ../reservations.ts) -------------
610
+ async buildReservation(change, snap) {
611
+ const raw = await this.client.accessControl.listDhcpReservations(snap.siteId);
612
+ const world = { networks: snap.networks, reservations: toReservations(raw), clients: snap.clients };
613
+ const rawOf = (mac) => raw.find((r) => normalizeMac(r.mac) === normalizeMac(mac));
614
+ // The controller's and this server's own reservations are never moved or removed; while where
615
+ // either is can't be told, no existing reservation is (like ports with devices on them).
616
+ const guard = (mac, ips) => {
617
+ const at = (addresses) => Array.isArray(addresses) && ips.some((ip) => ip !== undefined && addresses.includes(ipToNumber(ip)));
618
+ const who = at(this.controllerAddresses) ? 'the controller' : at(this.serverAddresses) ? 'this server' : undefined;
619
+ if (who)
620
+ throw new NetError('PROTECTED', `${mac} is ${who}: its address is never moved or released through the tools`);
621
+ const blind = !this.options.controllerHost
622
+ ? "the controller's address isn't known"
623
+ : this.controllerAddresses === 'unknown'
624
+ ? "the controller's address can't be resolved"
625
+ : this.serverAddresses === 'unknown'
626
+ ? "this server's address can't be resolved"
627
+ : this.serverAddresses === 'unset'
628
+ ? "this server's address isn't configured (MCP_SERVER_ADDRESSES)"
629
+ : undefined;
630
+ if (blind)
631
+ throw new NetError('PROTECTED', `${blind}, so its reservation can't be told apart: existing reservations are not moved or removed (a new one, or pinning a device to the address it has, still works)`);
632
+ };
633
+ const online = (mac) => snap.clients.find((c) => c.online && normalizeMac(c.mac) === normalizeMac(mac));
634
+ const extra = { reservations: raw.map(canonical) };
635
+ if (change.op === 'delete') {
636
+ const checked = checkReservationDelete(change.mac, world);
637
+ guard(checked.mac, [checked.current.ip, online(checked.mac)?.ip]);
638
+ const target = formatMac(rawOf(checked.mac).mac);
639
+ return {
640
+ steps: [{ ...checked.step, object: 'omada.dhcpReservation' }],
641
+ effects: checked.effects,
642
+ wouldLockOut: [],
643
+ reads: { networks: true },
644
+ extra,
645
+ execute: async (s) => {
646
+ await this.client.networkConfig.deleteDhcpReservation(target, s.siteId);
647
+ const after = await this.readBack(s.siteId, checked.mac);
648
+ if (after === 'unread')
649
+ return { applied: true, owned: [], warnings: [UNREAD] };
650
+ if (after)
651
+ throw new NetError('CONFLICT', `The controller accepted the removal but still shows a reservation for ${checked.mac}; check it with net_listReservations.`);
652
+ return { applied: true, owned: [] };
653
+ },
654
+ };
655
+ }
656
+ const checked = checkReservationSet(change.value, world, { descriptions: true });
657
+ const device = online(checked.mac);
658
+ // Their address is theirs: reserved for anyone else, that device could take it at its next DHCP request.
659
+ const target = ipToNumber(checked.ip);
660
+ for (const [who, addresses] of [['the controller', this.controllerAddresses], ['this server', this.serverAddresses]]) {
661
+ if (Array.isArray(addresses) && addresses.includes(target) && device?.ip !== checked.ip) {
662
+ throw new NetError('PROTECTED', `${checked.ip} is ${who}'s address; it can't be reserved for another device`);
663
+ }
664
+ }
665
+ // Pinning such a device to the address it has is fine; moving it is not.
666
+ if ((checked.current && checked.current.ip !== checked.ip) || (device?.ip !== undefined && device.ip !== checked.ip))
667
+ guard(checked.mac, [checked.current?.ip, device?.ip]);
668
+ const before = rawOf(checked.mac);
669
+ const body = {
670
+ netId: checked.network.id,
671
+ mac: checked.mac,
672
+ ip: checked.ip,
673
+ description: checked.description || undefined,
674
+ status: true,
675
+ options: before?.options ?? [],
676
+ };
677
+ return {
678
+ steps: [{ ...checked.step, object: 'omada.dhcpReservation' }],
679
+ effects: checked.effects,
680
+ wouldLockOut: checked.wouldLockOut,
681
+ reads: { networks: true },
682
+ // The devices it moves, with the address the dry run saw them at: one elsewhere by now is a CONFLICT.
683
+ extra: { ...extra, moving: checked.wouldLockOut.map((c) => `${normalizeMac(c.mac)}@${c.ip ?? ''}`).sort() },
684
+ execute: async (s) => {
685
+ if (before)
686
+ await this.client.networkConfig.updateDhcpReservation(formatMac(before.mac), body, s.siteId);
687
+ else
688
+ await this.client.networkConfig.createDhcpReservation(body, s.siteId);
689
+ const after = await this.readBack(s.siteId, checked.mac);
690
+ if (after === 'unread')
691
+ return { applied: true, owned: [], warnings: [UNREAD] };
692
+ if (!after || after.ip !== checked.ip || !('id' in after.network) || after.network.id !== checked.network.id || after.description !== checked.description) {
693
+ throw new NetError('CONFLICT', `The controller accepted the reservation but shows something else for ${checked.mac} now; check it with net_listReservations.`);
694
+ }
695
+ return { applied: true, object: after, owned: [] };
696
+ },
697
+ };
698
+ }
699
+ /** The device's reservation as the controller has it now (undefined: none; 'unread': the read failed). */
700
+ async readBack(siteId, mac) {
701
+ let rows;
702
+ try {
703
+ rows = await this.client.accessControl.listDhcpReservations(siteId);
704
+ }
705
+ catch {
706
+ return 'unread';
707
+ }
708
+ return toReservations(rows).find((r) => normalizeMac(r.mac) === normalizeMac(mac));
709
+ }
611
710
  // ---- plan / apply ------------------------------------------------------------------
612
711
  async plan(change) {
613
712
  return await this.planWith(change, await this.snapshot());
@@ -672,6 +771,8 @@ export class OmadaNetworkController {
672
771
  throw unsupported('creating or deleting port profiles here (do it in the controller web UI)', 'ports.profiles');
673
772
  case 'mdnsRepeater':
674
773
  return await this.buildMdnsSet(change.enabled, change.networks, snap);
774
+ case 'reservation':
775
+ return await this.buildReservation(change, snap);
675
776
  case 'firmware':
676
777
  // The Open API can (start-online-upgrade), but it isn't live-tested here yet.
677
778
  throw new NetError('UNSUPPORTED', 'Upgrading Omada firmware through the net_ tools is not available yet; use the controller web UI (Devices > upgrade).', { capability: 'firmware' });
@@ -686,7 +787,7 @@ export class OmadaNetworkController {
686
787
  return this.buildRuleMove(change.ref, change.position, snap);
687
788
  break;
688
789
  default:
689
- throw notYet(`${change.kind} changes`, change.kind === 'group' ? 'a later release (use omada_createGroup / omada_deleteGroup)' : 'a later release (use omada_setDhcpReservation)');
790
+ throw notYet(`${change.kind} changes`, 'a later release (use omada_createGroup / omada_deleteGroup)');
690
791
  }
691
792
  throw new NetError('INVALID', 'This change is not supported');
692
793
  }
@@ -1708,6 +1809,13 @@ function describeNetworkChanges(current, next) {
1708
1809
  * rules are sets (ids of networks, services...), so the same state listed in another order
1709
1810
  * fingerprints the same.
1710
1811
  */
1812
+ const UNREAD = 'The reservation change was accepted, but reading it back failed; check it with net_listReservations.';
1813
+ /** Controller reservations in the model (entries without an address are left out). */
1814
+ function toReservations(rows) {
1815
+ return rows
1816
+ .filter((r) => typeof r.ip === 'string')
1817
+ .map((r) => ({ mac: formatMac(r.mac), ip: String(r.ip), network: { id: r.netId }, ...(r.description ? { description: String(r.description) } : {}) }));
1818
+ }
1711
1819
  function canonical(value) {
1712
1820
  if (Array.isArray(value)) {
1713
1821
  return value