@owine/unifi-network-mcp 3.0.0 → 3.2.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 +47 -17
- package/dist/client.d.ts +21 -1
- package/dist/client.js +133 -24
- package/dist/config.d.ts +1 -0
- package/dist/config.js +6 -0
- package/dist/guard.d.ts +3 -3
- package/dist/guard.js +18 -9
- package/dist/index.js +17 -0
- package/dist/instructions.js +1 -1
- package/dist/tools/acl.js +2 -3
- package/dist/tools/classic.d.ts +3 -0
- package/dist/tools/classic.js +54 -0
- package/dist/tools/client-history.js +2 -2
- package/dist/tools/devices.js +1 -1
- package/dist/tools/exposure.d.ts +3 -0
- package/dist/tools/exposure.js +118 -0
- package/dist/tools/firewall.js +8 -8
- package/dist/tools/index.js +5 -1
- package/dist/utils/list-all.d.ts +4 -0
- package/dist/utils/list-all.js +21 -0
- package/dist/utils/output-schemas.d.ts +216 -0
- package/dist/utils/output-schemas.js +147 -1
- package/dist/utils/params.d.ts +5 -0
- package/dist/utils/params.js +9 -0
- package/dist/utils/pick.d.ts +6 -0
- package/dist/utils/pick.js +11 -0
- package/dist/utils/responses.d.ts +6 -0
- package/dist/utils/responses.js +11 -4
- package/dist/utils/safety.d.ts +20 -3
- package/dist/utils/safety.js +36 -6
- package/dist/utils/wan-exposure.js +3 -18
- package/package.json +10 -10
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.listClientSessionsOutputSchema = exports.listClientHistoryOutputSchema = exports.ClientSessionEnvelope = exports.ClientSession = exports.HistoryClient = exports.listPendingDevicesOutputSchema = exports.listCountriesOutputSchema = exports.listDpiApplicationsOutputSchema = exports.listDpiCategoriesOutputSchema = exports.listDeviceTagsOutputSchema = exports.listRadiusProfilesOutputSchema = exports.listVpnTunnelsOutputSchema = exports.listVpnServersOutputSchema = exports.listWansOutputSchema = exports.getWifiOutputSchema = exports.listWifiOutputSchema = exports.getClientOutputSchema = exports.listClientsOutputSchema = exports.getDeviceStatisticsOutputSchema = exports.getDeviceOutputSchema = exports.listDevicesOutputSchema = exports.listTrafficMatchingListsOutputSchema = exports.trafficMatchingListOutputSchema = exports.listDnsPoliciesOutputSchema = exports.dnsPolicyOutputSchema = exports.listLagsOutputSchema = exports.lagOutputSchema = exports.listMcLagDomainsOutputSchema = exports.mcLagDomainOutputSchema = exports.listSwitchStacksOutputSchema = exports.switchStackOutputSchema = exports.aclRuleOrderingOutputSchema = exports.listAclRulesOutputSchema = exports.aclRuleOutputSchema = exports.firewallPolicyOrderingOutputSchema = exports.listFirewallPoliciesOutputSchema = exports.firewallPolicyOutputSchema = exports.listFirewallZonesOutputSchema = exports.firewallZoneOutputSchema = exports.createVouchersOutputSchema = exports.listVouchersOutputSchema = exports.voucherOutputSchema = exports.getNetworkReferencesOutputSchema = exports.listNetworksOutputSchema = exports.networkOutputSchema = exports.listSitesOutputSchema = exports.getInfoOutputSchema = void 0;
|
|
3
|
+
exports.LegacyFirewallRule = exports.ClassicFirewallGroup = exports.ClassicNetwork = exports.listClientSessionsOutputSchema = exports.listClientHistoryOutputSchema = exports.ClientSessionEnvelope = exports.ClientSession = exports.HistoryClient = exports.listPendingDevicesOutputSchema = exports.listCountriesOutputSchema = exports.listDpiApplicationsOutputSchema = exports.listDpiCategoriesOutputSchema = exports.listDeviceTagsOutputSchema = exports.listRadiusProfilesOutputSchema = exports.listVpnTunnelsOutputSchema = exports.listVpnServersOutputSchema = exports.listWansOutputSchema = exports.getWifiOutputSchema = exports.listWifiOutputSchema = exports.getClientOutputSchema = exports.listClientsOutputSchema = exports.getDeviceStatisticsOutputSchema = exports.getDeviceOutputSchema = exports.listDevicesOutputSchema = exports.listTrafficMatchingListsOutputSchema = exports.trafficMatchingListOutputSchema = exports.listDnsPoliciesOutputSchema = exports.dnsPolicyOutputSchema = exports.listLagsOutputSchema = exports.lagOutputSchema = exports.listMcLagDomainsOutputSchema = exports.mcLagDomainOutputSchema = exports.listSwitchStacksOutputSchema = exports.switchStackOutputSchema = exports.aclRuleOrderingOutputSchema = exports.listAclRulesOutputSchema = exports.aclRuleOutputSchema = exports.firewallPolicyOrderingOutputSchema = exports.listFirewallPoliciesOutputSchema = exports.firewallPolicyOutputSchema = exports.listFirewallZonesOutputSchema = exports.firewallZoneOutputSchema = exports.createVouchersOutputSchema = exports.listVouchersOutputSchema = exports.voucherOutputSchema = exports.getNetworkReferencesOutputSchema = exports.listNetworksOutputSchema = exports.networkOutputSchema = exports.listSitesOutputSchema = exports.getInfoOutputSchema = void 0;
|
|
4
|
+
exports.getDeviceSshSettingsOutputSchema = exports.getUpnpStatusOutputSchema = exports.listPortForwardsOutputSchema = exports.PortForward = exports.PortForwardDestination = exports.listLegacyFirewallRulesOutputSchema = exports.listFirewallGroupsOutputSchema = exports.listClassicNetworksOutputSchema = exports.ClassicEnvelope = void 0;
|
|
4
5
|
exports.looseOutput = looseOutput;
|
|
5
6
|
exports.writeResult = writeResult;
|
|
6
7
|
/**
|
|
@@ -74,6 +75,8 @@ const DeviceRadio = zod_1.z
|
|
|
74
75
|
.object({
|
|
75
76
|
wlanStandard: zod_1.z.string().optional(),
|
|
76
77
|
frequencyGHz: zod_1.z.number().optional(),
|
|
78
|
+
// Undocumented; live-verified on 11.1.47 (e.g. "MEDIUM"), absent on 11.0.83.
|
|
79
|
+
txPowerMode: zod_1.z.string().optional(),
|
|
77
80
|
channelWidthMHz: zod_1.z.number().optional(),
|
|
78
81
|
channel: zod_1.z.number().optional(),
|
|
79
82
|
})
|
|
@@ -618,3 +621,146 @@ exports.listClientSessionsOutputSchema = {
|
|
|
618
621
|
end: zod_1.z.number(),
|
|
619
622
|
coverage: zod_1.z.string(),
|
|
620
623
|
};
|
|
624
|
+
// Classic controller API reads (rest/networkconf, rest/firewallgroup,
|
|
625
|
+
// rest/firewallrule, rest/portforward, get/setting). Unlike every other schema
|
|
626
|
+
// here, each row shape is also the tool's output allowlist: the tools copy only
|
|
627
|
+
// these fields from a record, so a field the controller adds never reaches the
|
|
628
|
+
// model. networkconf records carry WAN, VPN and RADIUS credentials
|
|
629
|
+
// (x_passphrase, x_wan_password, x_ipsec_*, x_openvpn_*, WireGuard keys, PPPoE
|
|
630
|
+
// logins), and settings sections carry more (x_ssh_password, x_mgmt_key,
|
|
631
|
+
// cloud-access keys); never list one here. Field types vary across controller
|
|
632
|
+
// versions, hence the unions.
|
|
633
|
+
const classicScalar = zod_1.z.union([zod_1.z.string(), zod_1.z.number()]).nullish();
|
|
634
|
+
const classicString = zod_1.z.string().nullish();
|
|
635
|
+
const classicBoolean = zod_1.z.boolean().nullish();
|
|
636
|
+
exports.ClassicNetwork = zod_1.z
|
|
637
|
+
.object({
|
|
638
|
+
_id: zod_1.z.string(),
|
|
639
|
+
name: classicString,
|
|
640
|
+
purpose: classicString,
|
|
641
|
+
vlan: classicScalar,
|
|
642
|
+
vlan_enabled: classicBoolean,
|
|
643
|
+
ip_subnet: classicString,
|
|
644
|
+
networkgroup: classicString,
|
|
645
|
+
enabled: classicBoolean,
|
|
646
|
+
dhcpd_enabled: classicBoolean,
|
|
647
|
+
dhcpd_start: classicString,
|
|
648
|
+
dhcpd_stop: classicString,
|
|
649
|
+
domain_name: classicString,
|
|
650
|
+
igmp_snooping: classicBoolean,
|
|
651
|
+
mdns_enabled: classicBoolean,
|
|
652
|
+
network_isolation_enabled: classicBoolean,
|
|
653
|
+
internet_access_enabled: classicBoolean,
|
|
654
|
+
firewall_zone_id: classicString,
|
|
655
|
+
is_nat: classicBoolean,
|
|
656
|
+
ipv6_interface_type: classicString,
|
|
657
|
+
setting_preference: classicString,
|
|
658
|
+
})
|
|
659
|
+
.loose();
|
|
660
|
+
exports.ClassicFirewallGroup = zod_1.z
|
|
661
|
+
.object({
|
|
662
|
+
_id: zod_1.z.string(),
|
|
663
|
+
name: classicString,
|
|
664
|
+
group_type: classicString,
|
|
665
|
+
group_members: zod_1.z.array(zod_1.z.string()).nullish(),
|
|
666
|
+
})
|
|
667
|
+
.loose();
|
|
668
|
+
exports.LegacyFirewallRule = zod_1.z
|
|
669
|
+
.object({
|
|
670
|
+
_id: zod_1.z.string(),
|
|
671
|
+
name: classicString,
|
|
672
|
+
enabled: classicBoolean,
|
|
673
|
+
ruleset: classicString,
|
|
674
|
+
rule_index: classicScalar,
|
|
675
|
+
action: classicString,
|
|
676
|
+
protocol: classicString,
|
|
677
|
+
protocol_v6: classicString,
|
|
678
|
+
protocol_match_excepted: classicBoolean,
|
|
679
|
+
icmp_typename: classicString,
|
|
680
|
+
icmpv6_typename: classicString,
|
|
681
|
+
logging: classicBoolean,
|
|
682
|
+
state_established: classicBoolean,
|
|
683
|
+
state_related: classicBoolean,
|
|
684
|
+
state_new: classicBoolean,
|
|
685
|
+
state_invalid: classicBoolean,
|
|
686
|
+
src_firewallgroup_ids: zod_1.z.array(zod_1.z.string()).nullish(),
|
|
687
|
+
src_networkconf_id: classicString,
|
|
688
|
+
src_networkconf_type: classicString,
|
|
689
|
+
src_address: classicString,
|
|
690
|
+
src_address_ipv6: classicString,
|
|
691
|
+
src_mac_address: classicString,
|
|
692
|
+
src_port: classicScalar,
|
|
693
|
+
dst_firewallgroup_ids: zod_1.z.array(zod_1.z.string()).nullish(),
|
|
694
|
+
dst_networkconf_id: classicString,
|
|
695
|
+
dst_networkconf_type: classicString,
|
|
696
|
+
dst_address: classicString,
|
|
697
|
+
dst_address_ipv6: classicString,
|
|
698
|
+
dst_port: classicScalar,
|
|
699
|
+
ipsec: classicString,
|
|
700
|
+
setting_preference: classicString,
|
|
701
|
+
})
|
|
702
|
+
.loose();
|
|
703
|
+
/** Envelope of a classic REST collection; rows stay unknown until the allowlist is applied. */
|
|
704
|
+
exports.ClassicEnvelope = zod_1.z.object({
|
|
705
|
+
meta: zod_1.z.object({ rc: zod_1.z.literal("ok") }).loose(),
|
|
706
|
+
data: zod_1.z.array(zod_1.z.record(zod_1.z.string(), zod_1.z.unknown())),
|
|
707
|
+
});
|
|
708
|
+
const classicList = (row) => ({
|
|
709
|
+
data: zod_1.z.array(row),
|
|
710
|
+
count: zod_1.z.number(),
|
|
711
|
+
siteReference: zod_1.z.string(),
|
|
712
|
+
});
|
|
713
|
+
exports.listClassicNetworksOutputSchema = classicList(exports.ClassicNetwork);
|
|
714
|
+
exports.listFirewallGroupsOutputSchema = classicList(exports.ClassicFirewallGroup);
|
|
715
|
+
exports.listLegacyFirewallRulesOutputSchema = classicList(exports.LegacyFirewallRule);
|
|
716
|
+
exports.PortForwardDestination = zod_1.z
|
|
717
|
+
.object({
|
|
718
|
+
destination_ip: classicString,
|
|
719
|
+
interface: classicString,
|
|
720
|
+
})
|
|
721
|
+
.loose();
|
|
722
|
+
exports.PortForward = zod_1.z
|
|
723
|
+
.object({
|
|
724
|
+
_id: zod_1.z.string(),
|
|
725
|
+
name: classicString,
|
|
726
|
+
enabled: classicBoolean,
|
|
727
|
+
pfwd_interface: classicString,
|
|
728
|
+
destination_ip: classicString,
|
|
729
|
+
destination_ips: zod_1.z.array(exports.PortForwardDestination).nullish(),
|
|
730
|
+
src: classicString,
|
|
731
|
+
src_limiting_enabled: classicBoolean,
|
|
732
|
+
src_limiting_type: classicString,
|
|
733
|
+
src_firewall_group_id: classicString,
|
|
734
|
+
dst_port: classicScalar,
|
|
735
|
+
fwd: classicString,
|
|
736
|
+
fwd_port: classicScalar,
|
|
737
|
+
proto: classicString,
|
|
738
|
+
log: classicBoolean,
|
|
739
|
+
})
|
|
740
|
+
.loose();
|
|
741
|
+
exports.listPortForwardsOutputSchema = {
|
|
742
|
+
data: zod_1.z.array(exports.PortForward),
|
|
743
|
+
count: zod_1.z.number(),
|
|
744
|
+
enabledCount: zod_1.z.number(),
|
|
745
|
+
siteReference: zod_1.z.string(),
|
|
746
|
+
};
|
|
747
|
+
const settingsStatus = zod_1.z.enum(["available", "unavailable"]);
|
|
748
|
+
exports.getUpnpStatusOutputSchema = {
|
|
749
|
+
siteReference: zod_1.z.string(),
|
|
750
|
+
status: settingsStatus,
|
|
751
|
+
source: zod_1.z.string().nullable(),
|
|
752
|
+
upnp_enabled: zod_1.z.boolean().nullable(),
|
|
753
|
+
upnp_nat_pmp_enabled: zod_1.z.boolean().nullable(),
|
|
754
|
+
upnp_secure_mode: zod_1.z.boolean().nullable(),
|
|
755
|
+
upnp_wan_interface: zod_1.z.string().nullable(),
|
|
756
|
+
detail: zod_1.z.string(),
|
|
757
|
+
};
|
|
758
|
+
exports.getDeviceSshSettingsOutputSchema = {
|
|
759
|
+
siteReference: zod_1.z.string(),
|
|
760
|
+
status: settingsStatus,
|
|
761
|
+
x_ssh_enabled: zod_1.z.boolean().nullable(),
|
|
762
|
+
x_ssh_auth_password_enabled: zod_1.z.boolean().nullable(),
|
|
763
|
+
x_ssh_bind_wildcard: zod_1.z.boolean().nullable(),
|
|
764
|
+
sshKeyCount: zod_1.z.number().nullable(),
|
|
765
|
+
detail: zod_1.z.string(),
|
|
766
|
+
};
|
package/dist/utils/params.d.ts
CHANGED
|
@@ -5,3 +5,8 @@ import { z } from "zod";
|
|
|
5
5
|
* (`?force=true`), a fragment, or extra path segments into the request.
|
|
6
6
|
*/
|
|
7
7
|
export declare function resourceId(): z.ZodString;
|
|
8
|
+
/**
|
|
9
|
+
* A site's internalReference (often default), which the controller routes
|
|
10
|
+
* outside the Integration API take in place of the site UUID.
|
|
11
|
+
*/
|
|
12
|
+
export declare function siteReference(): z.ZodString;
|
package/dist/utils/params.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.resourceId = resourceId;
|
|
4
|
+
exports.siteReference = siteReference;
|
|
4
5
|
const zod_1 = require("zod");
|
|
5
6
|
/**
|
|
6
7
|
* An Integration API resource ID. IDs are interpolated into request paths, so
|
|
@@ -10,3 +11,11 @@ const zod_1 = require("zod");
|
|
|
10
11
|
function resourceId() {
|
|
11
12
|
return zod_1.z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, "Use an ID returned by a list or get tool");
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* A site's internalReference (often default), which the controller routes
|
|
16
|
+
* outside the Integration API take in place of the site UUID.
|
|
17
|
+
*/
|
|
18
|
+
function siteReference() {
|
|
19
|
+
return zod_1.z.string().regex(/^[A-Za-z0-9_-]{1,64}$/)
|
|
20
|
+
.describe("Site internalReference from unifi_list_sites (often default), not the Integration API UUID");
|
|
21
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy only the allowlisted fields of a record. Classic records hold
|
|
3
|
+
* credentials under names no redaction list could anticipate, so the output is
|
|
4
|
+
* built up from the schema's keys and nothing is ever passed through.
|
|
5
|
+
*/
|
|
6
|
+
export declare function pick(row: Record<string, unknown>, fields: readonly string[]): Record<string, unknown>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.pick = pick;
|
|
4
|
+
/**
|
|
5
|
+
* Copy only the allowlisted fields of a record. Classic records hold
|
|
6
|
+
* credentials under names no redaction list could anticipate, so the output is
|
|
7
|
+
* built up from the schema's keys and nothing is ever passed through.
|
|
8
|
+
*/
|
|
9
|
+
function pick(row, fields) {
|
|
10
|
+
return Object.fromEntries(fields.filter((field) => Object.hasOwn(row, field)).map((field) => [field, row[field]]));
|
|
11
|
+
}
|
|
@@ -9,6 +9,12 @@ export declare function formatSuccess(data: unknown, opts?: {
|
|
|
9
9
|
};
|
|
10
10
|
/** The message of a thrown value, which need not be an Error. */
|
|
11
11
|
export declare function errorMessage(err: unknown): string;
|
|
12
|
+
/**
|
|
13
|
+
* The message of a failed request, with the reason fetch keeps in cause: fetch
|
|
14
|
+
* reports every network failure as "fetch failed" and keeps the reason
|
|
15
|
+
* (ECONNREFUSED, ENOTFOUND, a certificate error) there.
|
|
16
|
+
*/
|
|
17
|
+
export declare function describeError(err: unknown): string;
|
|
12
18
|
export declare function formatError(err: unknown): {
|
|
13
19
|
content: {
|
|
14
20
|
type: "text";
|
package/dist/utils/responses.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.formatSuccess = formatSuccess;
|
|
4
4
|
exports.errorMessage = errorMessage;
|
|
5
|
+
exports.describeError = describeError;
|
|
5
6
|
exports.formatError = formatError;
|
|
6
7
|
function formatSuccess(data, opts) {
|
|
7
8
|
const result = {
|
|
@@ -19,18 +20,24 @@ function formatSuccess(data, opts) {
|
|
|
19
20
|
function errorMessage(err) {
|
|
20
21
|
return err instanceof Error ? err.message : String(err);
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The message of a failed request, with the reason fetch keeps in cause: fetch
|
|
25
|
+
* reports every network failure as "fetch failed" and keeps the reason
|
|
26
|
+
* (ECONNREFUSED, ENOTFOUND, a certificate error) there.
|
|
27
|
+
*/
|
|
28
|
+
function describeError(err) {
|
|
23
29
|
let message = errorMessage(err);
|
|
24
|
-
// fetch reports every network failure as "fetch failed" and keeps the reason
|
|
25
|
-
// (ECONNREFUSED, ENOTFOUND, a certificate error) in cause.
|
|
26
30
|
const cause = err instanceof Error ? err.cause : undefined;
|
|
27
31
|
const code = cause instanceof Error && "code" in cause && typeof cause.code === "string" ? cause.code : undefined;
|
|
28
32
|
if (code) {
|
|
29
33
|
message += ` (${code})`;
|
|
30
34
|
message += tlsHint(code);
|
|
31
35
|
}
|
|
36
|
+
return message;
|
|
37
|
+
}
|
|
38
|
+
function formatError(err) {
|
|
32
39
|
return {
|
|
33
|
-
content: [{ type: "text", text: `Error: ${
|
|
40
|
+
content: [{ type: "text", text: `Error: ${describeError(err)}` }],
|
|
34
41
|
isError: true,
|
|
35
42
|
};
|
|
36
43
|
}
|
package/dist/utils/safety.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import type { NetworkClient } from "../client.js";
|
|
3
4
|
/** The confirm input of a DESTRUCTIVE tool. */
|
|
4
5
|
export declare const confirmDestructive: () => z.ZodOptional<z.ZodBoolean>;
|
|
5
6
|
export declare const READ_ONLY: ToolAnnotations;
|
|
@@ -36,13 +37,28 @@ export interface RuleEffect {
|
|
|
36
37
|
letsThrough: boolean;
|
|
37
38
|
blocks: boolean;
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* A firewall policy or ACL rule as a list returns it, typed only as far as a
|
|
42
|
+
* reorder check reads it. An entry without an ID cannot be in an ordering, so
|
|
43
|
+
* it is skipped rather than failing the check.
|
|
44
|
+
*/
|
|
45
|
+
declare const ListedRule: z.ZodObject<{
|
|
46
|
+
id: z.ZodOptional<z.ZodString>;
|
|
47
|
+
enabled: z.ZodOptional<z.ZodUnknown>;
|
|
48
|
+
action: z.ZodOptional<z.ZodUnknown>;
|
|
49
|
+
}, z.core.$loose>;
|
|
50
|
+
/** Every rule of a list by ID, with its effect, for reorderOpens. */
|
|
51
|
+
export declare function listedEffects(client: NetworkClient, path: string, effectOf: (rule: z.infer<typeof ListedRule>) => RuleEffect): Promise<Map<string, RuleEffect>>;
|
|
39
52
|
/**
|
|
40
53
|
* Whether a reorder opens access: whether it moves a rule that lets traffic
|
|
41
54
|
* through ahead of one that blocks, which it used to follow. Only rules in
|
|
42
|
-
* both orders are compared
|
|
43
|
-
*
|
|
55
|
+
* both orders are compared. effects reads every rule's effect at once (one
|
|
56
|
+
* list read, not a GET per rule, which a large reshuffle would turn into a
|
|
57
|
+
* burst of hundreds), and is called only when the reorder moves rules past
|
|
58
|
+
* one another. A moved rule it does not return (deleted since the ordering
|
|
59
|
+
* was read) is refused rather than guessed. IDs are compared without case.
|
|
44
60
|
*/
|
|
45
|
-
export declare function reorderOpens(before: readonly string[], after: readonly string[],
|
|
61
|
+
export declare function reorderOpens(before: readonly string[], after: readonly string[], effects: () => Promise<ReadonlyMap<string, RuleEffect>>): Promise<boolean>;
|
|
46
62
|
export declare function requireConfirmation(confirm: boolean | undefined, action: string): {
|
|
47
63
|
content: {
|
|
48
64
|
type: "text";
|
|
@@ -50,3 +66,4 @@ export declare function requireConfirmation(confirm: boolean | undefined, action
|
|
|
50
66
|
}[];
|
|
51
67
|
isError: true;
|
|
52
68
|
} | null;
|
|
69
|
+
export {};
|
package/dist/utils/safety.js
CHANGED
|
@@ -3,10 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.DESTRUCTIVE = exports.WRITE_NOT_IDEMPOTENT = exports.WRITE = exports.READ_ONLY = exports.confirmDestructive = void 0;
|
|
4
4
|
exports.formatDryRun = formatDryRun;
|
|
5
5
|
exports.requireConfirmationIf = requireConfirmationIf;
|
|
6
|
+
exports.listedEffects = listedEffects;
|
|
6
7
|
exports.reorderOpens = reorderOpens;
|
|
7
8
|
exports.requireConfirmation = requireConfirmation;
|
|
8
9
|
const zod_1 = require("zod");
|
|
9
10
|
const write_log_js_1 = require("../write-log.js");
|
|
11
|
+
const list_all_js_1 = require("./list-all.js");
|
|
12
|
+
const responses_js_1 = require("./responses.js");
|
|
10
13
|
/** The confirm input of a DESTRUCTIVE tool. */
|
|
11
14
|
const confirmDestructive = () => zod_1.z.boolean().optional().describe("Must be true to execute this destructive action; not needed with dryRun: true");
|
|
12
15
|
exports.confirmDestructive = confirmDestructive;
|
|
@@ -60,13 +63,33 @@ function formatDryRun(method, path, body, confirmationRequired) {
|
|
|
60
63
|
function requireConfirmationIf(reason, confirm, dryRun) {
|
|
61
64
|
return reason === undefined || dryRun ? null : requireConfirmation(confirm, reason);
|
|
62
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* A firewall policy or ACL rule as a list returns it, typed only as far as a
|
|
68
|
+
* reorder check reads it. An entry without an ID cannot be in an ordering, so
|
|
69
|
+
* it is skipped rather than failing the check.
|
|
70
|
+
*/
|
|
71
|
+
const ListedRule = zod_1.z.object({
|
|
72
|
+
id: zod_1.z.string().optional(),
|
|
73
|
+
enabled: zod_1.z.unknown().optional(),
|
|
74
|
+
action: zod_1.z.unknown().optional(),
|
|
75
|
+
}).loose();
|
|
76
|
+
/** Every rule of a list by ID, with its effect, for reorderOpens. */
|
|
77
|
+
async function listedEffects(client, path, effectOf) {
|
|
78
|
+
const rules = await (0, list_all_js_1.listAll)(client, path, ListedRule).catch((err) => {
|
|
79
|
+
throw new Error(`the rules this reorder moves could not be read, so whether it opens access is unknown (${(0, responses_js_1.errorMessage)(err)})`, { cause: err });
|
|
80
|
+
});
|
|
81
|
+
return new Map(rules.flatMap((rule) => rule.id === undefined ? [] : [[rule.id, effectOf(rule)]]));
|
|
82
|
+
}
|
|
63
83
|
/**
|
|
64
84
|
* Whether a reorder opens access: whether it moves a rule that lets traffic
|
|
65
85
|
* through ahead of one that blocks, which it used to follow. Only rules in
|
|
66
|
-
* both orders are compared
|
|
67
|
-
*
|
|
86
|
+
* both orders are compared. effects reads every rule's effect at once (one
|
|
87
|
+
* list read, not a GET per rule, which a large reshuffle would turn into a
|
|
88
|
+
* burst of hundreds), and is called only when the reorder moves rules past
|
|
89
|
+
* one another. A moved rule it does not return (deleted since the ordering
|
|
90
|
+
* was read) is refused rather than guessed. IDs are compared without case.
|
|
68
91
|
*/
|
|
69
|
-
async function reorderOpens(before, after,
|
|
92
|
+
async function reorderOpens(before, after, effects) {
|
|
70
93
|
const rank = new Map(before.map((id, index) => [id.toLowerCase(), index]));
|
|
71
94
|
const passes = [];
|
|
72
95
|
after.forEach((ahead, i) => {
|
|
@@ -76,9 +99,16 @@ async function reorderOpens(before, after, effect) {
|
|
|
76
99
|
passes.push([ahead, behind]);
|
|
77
100
|
}
|
|
78
101
|
});
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
102
|
+
if (passes.length === 0)
|
|
103
|
+
return false;
|
|
104
|
+
const known = new Map([...(await effects())].map(([id, effect]) => [id.toLowerCase(), effect]));
|
|
105
|
+
// Every moved rule is checked first, so the answer never rests on a guess.
|
|
106
|
+
for (const id of new Set(passes.flat())) {
|
|
107
|
+
if (!known.has(id.toLowerCase())) {
|
|
108
|
+
throw new Error(`no rule with ID ${JSON.stringify(id)} is in this site's list, though the current ordering names it; read the ordering again`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return passes.some(([ahead, behind]) => known.get(ahead.toLowerCase())?.letsThrough === true && known.get(behind.toLowerCase())?.blocks === true);
|
|
82
112
|
}
|
|
83
113
|
function requireConfirmation(confirm, action) {
|
|
84
114
|
if (confirm !== true) {
|
|
@@ -5,6 +5,7 @@ exports.wanRefusal = wanRefusal;
|
|
|
5
5
|
const zod_1 = require("zod");
|
|
6
6
|
const write_log_js_1 = require("../write-log.js");
|
|
7
7
|
const responses_js_1 = require("./responses.js");
|
|
8
|
+
const list_all_js_1 = require("./list-all.js");
|
|
8
9
|
exports.DEFAULT_WAN_POLICY = { allowExposure: false, zoneIds: [] };
|
|
9
10
|
const Zone = zod_1.z.object({
|
|
10
11
|
id: zod_1.z.string(),
|
|
@@ -19,22 +20,6 @@ const Policy = zod_1.z.object({
|
|
|
19
20
|
source: zod_1.z.unknown().optional(),
|
|
20
21
|
destination: zod_1.z.unknown().optional(),
|
|
21
22
|
}).loose();
|
|
22
|
-
const PAGE_LIMIT = 200;
|
|
23
|
-
const MAX_PAGES = 50;
|
|
24
|
-
async function listAll(client, path, item) {
|
|
25
|
-
const page = zod_1.z.object({ data: zod_1.z.array(item), totalCount: zod_1.z.number().optional() }).loose();
|
|
26
|
-
const items = [];
|
|
27
|
-
for (let pages = 0; pages < MAX_PAGES; pages++) {
|
|
28
|
-
const { data, totalCount } = page.parse(await client.get(`${path}?offset=${items.length}&limit=${PAGE_LIMIT}`));
|
|
29
|
-
items.push(...data);
|
|
30
|
-
// Without a totalCount, only an empty page shows the list has ended: a
|
|
31
|
-
// controller may cap pages below PAGE_LIMIT, so a short page proves nothing.
|
|
32
|
-
if (data.length === 0 || (totalCount !== undefined && items.length >= totalCount))
|
|
33
|
-
return items;
|
|
34
|
-
}
|
|
35
|
-
// A list that does not end cannot be checked, which must not pass.
|
|
36
|
-
throw new Error(`the list did not end after ${MAX_PAGES} pages`);
|
|
37
|
-
}
|
|
38
23
|
class Refusal extends Error {
|
|
39
24
|
}
|
|
40
25
|
/** The checks for one site; zones and policies are read at most once per call. */
|
|
@@ -176,13 +161,13 @@ class WanExposure {
|
|
|
176
161
|
return zone;
|
|
177
162
|
}
|
|
178
163
|
zoneList() {
|
|
179
|
-
this.zones ??= listAll(this.client, `/sites/${this.siteId}/firewall/zones`, Zone).catch((err) => {
|
|
164
|
+
this.zones ??= (0, list_all_js_1.listAll)(this.client, `/sites/${this.siteId}/firewall/zones`, Zone).catch((err) => {
|
|
180
165
|
throw new Refusal(`the firewall zones could not be read to find the WAN zone (${(0, responses_js_1.errorMessage)(err)})`);
|
|
181
166
|
});
|
|
182
167
|
return this.zones;
|
|
183
168
|
}
|
|
184
169
|
policyList() {
|
|
185
|
-
this.policies ??= listAll(this.client, `/sites/${this.siteId}/firewall/policies`, Policy).catch((err) => {
|
|
170
|
+
this.policies ??= (0, list_all_js_1.listAll)(this.client, `/sites/${this.siteId}/firewall/policies`, Policy).catch((err) => {
|
|
186
171
|
throw new Refusal(`the firewall policies that the change depends on could not be read (${(0, responses_js_1.errorMessage)(err)})`);
|
|
187
172
|
});
|
|
188
173
|
return this.policies;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owine/unifi-network-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "MCP server exposing the UniFi Network Integration API as tools for Claude and other MCP clients",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
"test:package": "pnpm run build && sh scripts/smoke-package.sh"
|
|
22
22
|
},
|
|
23
23
|
"engines": {
|
|
24
|
-
"node": "^22.19.0 || ^24.0.0"
|
|
24
|
+
"node": "^22.19.0 || ^24.0.0 || ^26.0.0"
|
|
25
25
|
},
|
|
26
|
-
"packageManager": "pnpm@12.
|
|
26
|
+
"packageManager": "pnpm@12.21.0",
|
|
27
27
|
"repository": {
|
|
28
28
|
"type": "git",
|
|
29
29
|
"url": "git+https://github.com/owine/unifi-network-mcp.git"
|
|
@@ -40,19 +40,19 @@
|
|
|
40
40
|
"author": "owine",
|
|
41
41
|
"license": "MIT",
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@modelcontextprotocol/sdk": "1.32.
|
|
44
|
-
"undici": "8.12.
|
|
43
|
+
"@modelcontextprotocol/sdk": "1.32.2",
|
|
44
|
+
"undici": "8.12.4",
|
|
45
45
|
"zod": "4.6.7"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@eslint/js": "10.0.1",
|
|
49
|
-
"@types/node": "
|
|
49
|
+
"@types/node": "26.6.6",
|
|
50
50
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
51
|
-
"@vitest/coverage-v8": "5.0
|
|
52
|
-
"eslint": "10.
|
|
51
|
+
"@vitest/coverage-v8": "5.1.0",
|
|
52
|
+
"eslint": "10.15.0",
|
|
53
53
|
"lefthook": "2.2.2",
|
|
54
54
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
55
|
-
"typescript-eslint": "8.
|
|
56
|
-
"vitest": "5.0
|
|
55
|
+
"typescript-eslint": "8.72.2",
|
|
56
|
+
"vitest": "5.1.0"
|
|
57
57
|
}
|
|
58
58
|
}
|