@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 +11 -8
- package/dist/net/adapters/omada.d.ts +3 -0
- package/dist/net/adapters/omada.js +113 -5
- package/dist/net/adapters/omada.js.map +1 -1
- package/dist/net/adapters/ruijie.d.ts +13 -4
- package/dist/net/adapters/ruijie.js +95 -6
- package/dist/net/adapters/ruijie.js.map +1 -1
- package/dist/net/model.d.ts +5 -1
- package/dist/net/model.js.map +1 -1
- package/dist/net/reservations.d.ts +47 -0
- package/dist/net/reservations.js +146 -0
- package/dist/net/reservations.js.map +1 -0
- package/dist/omadaClient/networkConfig.d.ts +4 -0
- package/dist/omadaClient/networkConfig.js +11 -0
- package/dist/omadaClient/networkConfig.js.map +1 -1
- package/dist/tools/net/index.d.ts +15 -0
- package/dist/tools/net/index.js +33 -1
- package/dist/tools/net/index.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
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 (
|
|
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_`
|
|
93
|
-
In graph mode the Omada reads collapse into `omada_browse` + `omada_read` (Omada 26,
|
|
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 (
|
|
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
|
|
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,
|
|
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
|
-
|
|
314
|
-
Upgrade) is refused for now:
|
|
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
|
-
|
|
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`,
|
|
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
|