vpsnet-mcp 2.3.16 → 2.7.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.
@@ -1,17 +1,24 @@
1
1
  import { z } from "zod";
2
2
  import { apiRequest, formatJson } from "./api.js";
3
+ import { unboundedInteger } from "./tool-schema.js";
4
+ import { authorizationContext } from "./request-context.js";
3
5
  // Customer routes only. The account comes from X-API-KEY; caller-supplied
4
6
  // tenant IDs, worker IDs and internal endpoints never enter this registry.
5
7
  const uuid = z.string().length(36).regex(/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/)
6
8
  .describe("Exact lowercase UUID returned by an owned networking resource");
7
9
  const generation = z.number().int().safe().min(1);
8
10
  const policyGeneration = z.number().int().safe().min(0);
9
- const ipv4 = z.string().ip({ version: "v4" });
11
+ const ipv4 = z.ipv4();
12
+ // Preserve v3 IP syntax for the existing max-64-character source allowlist.
13
+ // In particular, scoped link-local IPv6 remains subject to backend eligibility.
14
+ const sourceIpv6 = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
10
15
  const port = z.number().int().min(1).max(65535);
11
16
  const key = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.:-]{15,189}$(?![\s\S])/)
12
17
  .describe("Stable Idempotency-Key for this exact intent; reuse unchanged on recovery, never generate a new key for an uncertain result");
13
18
  const quoteToken = z.string().min(1).regex(/^[^\s\x00-\x1f\x7f]+$/)
14
19
  .describe("Exact quote_token returned by the quote; sent only as X-Quote-Token");
20
+ const periodMonths = z.number().int().min(1).max(120)
21
+ .describe("Paid term in months from the current list_network_products catalog; omit for one month. The 1-120 bound is not an offer list. Disclose this term and the exact quoted total before confirmation.");
15
22
  const name = z.string().min(1).max(80).regex(/^[^\x00-\x1f\x7f]+$/);
16
23
  const dnsLabel = z.string().regex(/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/);
17
24
  const dnsAlias = z.object({ name: z.string().regex(/^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/), ipv4 }).strict();
@@ -61,9 +68,137 @@ const egressPolicy = z.object({
61
68
  exit_id: uuid.nullable().optional(), destinations: z.array(z.string()).max(64).optional(),
62
69
  dns: z.array(ipv4).max(8).optional(), client_route_opt_in: z.boolean().optional(),
63
70
  }).strict();
71
+ // The firewall of a server's public interface (backend PublicFirewallController, spec sdn-products-v1.yml
72
+ // PublicFirewallRule): incoming allow rules only; the security-group rule without remote_group_id.
73
+ const firewallCidr = z.string().max(43)
74
+ .regex(/^(?:\d{1,3}(?:\.\d{1,3}){3}\/(?:[0-9]|[12][0-9]|3[0-2])|[0-9A-Fa-f:]{2,39}\/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))$/)
75
+ .describe("Canonical source prefix of the rule's family, for example 198.51.100.7/32, 0.0.0.0/0, 2001:db8::/32 or ::/0. Never the server's own address.");
76
+ const firewallRule = z.object({
77
+ id: uuid.optional().describe("Optional: a rule without id that equals a stored rule keeps its id"),
78
+ direction: z.literal("ingress").describe("Only incoming rules are offered"),
79
+ ether_type: z.enum(["IPv4", "IPv6"]),
80
+ protocol: z.enum(["any", "tcp", "udp", "sctp", "icmp", "icmpv6"]),
81
+ port_min: port.optional(), port_max: port.optional(),
82
+ icmp_type: z.number().int().min(0).max(255).optional(), icmp_code: z.number().int().min(0).max(255).optional(),
83
+ remote_cidr: firewallCidr,
84
+ }).strict().superRefine((r, ctx) => {
85
+ if ((r.ether_type === "IPv4") !== r.remote_cidr.includes(".")) {
86
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "remote_cidr must be of the rule's ether_type" });
87
+ }
88
+ const ports = r.port_min !== undefined || r.port_max !== undefined;
89
+ const icmp = r.icmp_type !== undefined || r.icmp_code !== undefined;
90
+ if ((r.protocol === "any" && (ports || icmp))
91
+ || (["tcp", "udp", "sctp"].includes(r.protocol) && (icmp || (ports && (r.port_min === undefined || r.port_max === undefined || r.port_min > r.port_max))))
92
+ || (r.protocol === "icmp" && (r.ether_type !== "IPv4" || ports || (r.icmp_code !== undefined && r.icmp_type === undefined)))
93
+ || (r.protocol === "icmpv6" && (r.ether_type !== "IPv6" || ports || (r.icmp_code !== undefined && r.icmp_type === undefined)))) {
94
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Fields must match the protocol: ports both or none (tcp/udp/sctp), icmp IPv4 only, icmpv6 IPv6 only, ICMP code needs type, any takes neither" });
95
+ }
96
+ });
97
+ const portForwardRule = z.object({
98
+ protocol: z.enum(["tcp", "udp"]), external_port: port, private_port_id: uuid, internal_port: port,
99
+ }).strict();
100
+ const sitePeer = z.object({
101
+ remote_prefixes: z.array(z.string().regex(/^\d{1,3}(?:\.\d{1,3}){3}\/(?:[0-9]|[12][0-9]|3[0-2])$/)).min(1).max(16)
102
+ .describe("The office's PRIVATE IPv4 networks (10/8, 172.16/12, 192.168/16 or 100.64/10), canonical and disjoint"),
103
+ endpoint: z.string().regex(/^\d{1,3}(?:\.\d{1,3}){3}:\d{1,5}$/).nullable().optional()
104
+ .describe("The office router's fixed public IPv4:port; omit or null to learn a roaming endpoint"),
105
+ persistent_keepalive_seconds: z.number().int().min(0).max(600).optional(),
106
+ router_model: z.enum(["mikrotik", "pfsense", "opnsense", "unifi", "linux", "other"]).optional(),
107
+ }).strict();
108
+ const ipsecVendor = z.enum(["fortigate", "mikrotik", "pfsense", "opnsense", "cisco_ios_xe", "cisco_asa", "unifi", "junos_srx", "panos", "generic_strongswan", "generic_libreswan"])
109
+ .describe("Router recipe vendor ID from the current get_network_appliance_capabilities VPN ipsec_vendors catalog");
110
+ const ipsecTuning = z.object({
111
+ nat_traversal: z.enum(["auto", "off", "forced"]).optional(),
112
+ ke_groups: z.array(z.union([z.literal(19), z.literal(20), z.literal(21), z.literal(31), z.literal(15), z.literal(16), z.literal(14)])).nullable().optional()
113
+ .describe("Key exchange group IDs, exactly these. These seven are the most the platform can ever accept; the VPN's gateway ipsec_tuning.ke_groups lists the ones offered now (the backend refuses others with vpn_ipsec_tuning_not_allowed). null or omitted is Automatic."),
114
+ suites: z.array(z.enum(["aes256gcm", "chacha20poly1305", "aes128gcm", "aes256-sha512", "aes256-sha384", "aes256-sha256", "aes128-sha512", "aes128-sha384", "aes128-sha256"])).nullable().optional(),
115
+ pfs: z.enum(["auto", "on", "off"]).optional(),
116
+ ike_lifetime_seconds: z.number().int().min(900).max(172800).nullable().optional()
117
+ .describe("Phase 1 lifetime in seconds. 900-172800 is the outer limit; the gateway's ipsec_tuning.ike_lifetime {min, max} is the range offered now. null or omitted is Automatic."),
118
+ esp_lifetime_seconds: z.number().int().min(300).max(86400).nullable().optional()
119
+ .describe("Phase 2 lifetime in seconds. 300-86400 is the outer limit; the gateway's ipsec_tuning.esp_lifetime {min, max} is the range offered now. null or omitted is Automatic."),
120
+ }).strict().nullable();
121
+ const createBgp = z.object({
122
+ peer_asn: z.number().int().min(1).max(4294967294).optional(),
123
+ local_asn: z.number().int().min(1).max(4294967294).optional(),
124
+ accept: z.enum(["listed", "any_private"]).optional(), inner: ipv4.nullable().optional(),
125
+ keepalive_seconds: z.number().int().min(3).max(60).optional(),
126
+ hold_seconds: z.number().int().min(9).max(180).optional(),
127
+ }).strict().superRefine((bgp, ctx) => {
128
+ if ((bgp.peer_asn ?? 65000) === (bgp.local_asn ?? 64512))
129
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "BGP ASNs must differ" });
130
+ if ((bgp.hold_seconds ?? 30) < 3 * (bgp.keepalive_seconds ?? 10))
131
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "BGP hold time must be at least three times keepalive" });
132
+ });
133
+ const ipsecSitePeer = z.object({
134
+ remote_prefixes: z.array(sitePeer.shape.remote_prefixes.element).max(16).optional(),
135
+ bgp: createBgp.nullable().optional(), tuning: ipsecTuning.optional(),
136
+ remote_address: ipv4.nullable().optional().describe("The site's canonical public IPv4 address; omit or null for a dynamic address. No port. The backend validates address and prefix eligibility."),
137
+ vendor: ipsecVendor.optional().describe("Advertised router recipe vendor; omitted uses generic_strongswan"),
138
+ pqc: z.boolean().optional().describe("Request only when the gateway advertises pqc in ipsec_profiles; cannot be true together with legacy_compat. Requested PQC is not proof of negotiated post_quantum."),
139
+ legacy_compat: z.boolean().optional().describe("Request only when the gateway advertises legacy_compat in ipsec_profiles; cannot be true together with pqc"),
140
+ }).strict().superRefine((site, ctx) => {
141
+ if (site.pqc && site.legacy_compat)
142
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "pqc and legacy_compat cannot both be true" });
143
+ if (!site.remote_prefixes?.length && site.bgp?.accept !== "any_private")
144
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Static and listed BGP sites require remote_prefixes" });
145
+ if ((site.pqc || site.legacy_compat) && site.tuning && (site.tuning.ke_groups?.length || site.tuning.suites?.length || site.tuning.pfs && site.tuning.pfs !== "auto"))
146
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Pinned crypto conflicts with PQC or legacy compatibility" });
147
+ });
148
+ const grantScope = z.enum(["servers", "network"])
149
+ .describe("servers (default) chooses individual grants; network grants this device or site every eligible current and future server of this network, subject to network security policy. Network scope requires empty grants.");
64
150
  const publicKey = z.string().regex(/^[A-Za-z0-9+/]{43}=$/)
65
- .refine(value => Buffer.from(value, "base64").length === 32)
151
+ .refine(value => Buffer.from(value, "base64").length === 32 && Buffer.from(value, "base64").toString("base64") === value)
66
152
  .describe("Customer-generated one-time X25519 PUBLIC key only. Keep its private key outside MCP; needed to decrypt the sealed configuration.");
153
+ const webhookUrl = z.string().min(1).max(2048).refine(value => {
154
+ if (!value.startsWith("https://") || /[\x00-\x20\x7f\\?#]/.test(value))
155
+ return false;
156
+ try {
157
+ const parsed = new URL(value);
158
+ return parsed.protocol === "https:" && !parsed.username && !parsed.password && !parsed.search && !parsed.hash
159
+ && (parsed.port === "" || parsed.port === "443")
160
+ && parsed.hostname.length <= 253
161
+ && /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{1,62}$/.test(parsed.hostname)
162
+ && value.slice(8).split("/")[0].toLowerCase() === value.slice(8).split("/")[0];
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ }, "Use a public HTTPS DNS endpoint on port 443 without credentials, query or fragment; the backend verifies public DNS addresses.");
168
+ const reach = z.object({ load_balancers: z.boolean(), floating_ips: z.boolean() }).strict();
169
+ const publicAccessServices = z.array(z.object({
170
+ service: z.enum(["wireguard", "ipsec"]),
171
+ allow_from: z.array(z.string().max(64).refine(value => {
172
+ const [address, prefix, extra] = value.split("/");
173
+ if (extra !== undefined || !(ipv4.safeParse(address).success || sourceIpv6.test(address)))
174
+ return false;
175
+ return prefix === undefined || (/^[0-9]{1,3}$/.test(prefix) && Number(prefix) >= 1 && Number(prefix) <= (address.includes(":") ? 128 : 32));
176
+ }, "Use an IPv4/IPv6 address or canonical CIDR; backend validates eligible sources and host bits")).max(64),
177
+ }).strict()).max(2).refine(items => new Set(items.map(item => item.service)).size === items.length, "Each VPN service may appear only once");
178
+ const routerInputs = z.record(z.string(), z.union([z.string().max(256), z.number().int().safe()]))
179
+ .refine(values => Object.keys(values).length <= 32 && Object.keys(values).every(k => /^[a-z][a-z0-9_]{1,31}$/.test(k) && !/(?:secret|password|private|psk|token|key)/i.test(k)), "Router inputs must contain only nonsecret catalogue fields");
180
+ const recipeInputs = z.record(z.string(), z.union([z.string().max(256), z.number().int().safe()]))
181
+ .refine(values => JSON.stringify(values).length <= 2048 && Object.keys(values).every(k => /^(?:line2\.)?[a-z][a-z0-9_]{1,31}$/.test(k) && !/(?:secret|password|private|psk|token|key)/i.test(k)), "Recipe preview accepts only nonsecret backend catalogue fields, including line2 fields");
182
+ const bgpFields = {
183
+ expected_generation: generation,
184
+ enabled: z.literal(false).optional(),
185
+ remote_prefixes: sitePeer.shape.remote_prefixes.optional(),
186
+ peer_asn: z.number().int().min(1).max(4294967294).optional(),
187
+ local_asn: z.number().int().min(1).max(4294967294).optional(),
188
+ accept: z.enum(["listed", "any_private"]).optional(),
189
+ inner: ipv4.optional(),
190
+ keepalive_seconds: z.number().int().min(3).max(60).optional(),
191
+ hold_seconds: z.number().int().min(9).max(180).optional(),
192
+ };
193
+ function validateBgp(input) {
194
+ if (input.enabled === false && Object.keys(input).some(key => !["enabled", "remote_prefixes", "expected_generation", "gateway_id", "peer_id"].includes(key)))
195
+ throw new Error("Disabling BGP accepts only enabled=false, expected_generation and optional remote_prefixes.");
196
+ if (input.enabled !== false && input.peer_asn !== undefined && input.local_asn !== undefined && input.peer_asn === input.local_asn)
197
+ throw new Error("BGP peer and local ASN must differ.");
198
+ if (input.enabled !== false && input.keepalive_seconds !== undefined && input.hold_seconds !== undefined
199
+ && Number(input.hold_seconds) < 3 * Number(input.keepalive_seconds))
200
+ throw new Error("BGP hold_seconds must be at least three times keepalive_seconds.");
201
+ }
67
202
  const N = "/account/networking";
68
203
  const A = `${N}/appliances`;
69
204
  const networks = `${N}/networks/{network_id}`;
@@ -75,9 +210,48 @@ const sg = `${project}/security-groups`;
75
210
  const policy = `${project}/networks/{network_id}/security-policy`;
76
211
  const address = `${N}/public-addresses/{address_id}`;
77
212
  const binding = `${N}/public-bindings/{binding_id}`;
213
+ const firewall = `${N}/services/{service_id}/public-firewall`;
214
+ const firewallCaller = "client_address in the answer is the address the VPSnet API saw for this request: with this local package the public address of the machine that runs it, over a hosted connection the connector's gateway (returned there as null). It is never proof of the user's own address: ask the user which source address to allow before writing a rule with it.";
215
+ // Reverse DNS of the addresses that are not a server's own primary address (that one is get_rdns/change_rdns).
216
+ const rdnsTargets = {
217
+ address: `${N}/public-addresses/{resource_id}/rdns`,
218
+ assignment: `${N}/public-ip-assignments/{resource_id}/rdns`,
219
+ binding: `${N}/public-bindings/{resource_id}/rdns`,
220
+ vpn: `${A}/vpn-gateways/{resource_id}/rdns`,
221
+ load_balancer: `${A}/load-balancers/{resource_id}/rdns`,
222
+ };
223
+ const rdnsTarget = z.enum(["address", "assignment", "binding", "vpn", "load_balancer"])
224
+ .describe("What resource_id names: address = an owned additional public address (list_network_public_addresses), binding = a floating public binding, vpn = a VPN gateway (its public address), load_balancer = a public load balancer (its address), assignment = a current public IP assignment ID that an answer named. A server's own primary address uses get_rdns / change_rdns instead.");
225
+ const rdnsRevision = z.number().int().safe().min(0).describe("revision from the last get_public_ip_rdns read");
226
+ const rdnsSuffix = (suffix) => Object.fromEntries(Object.entries(rdnsTargets).map(([kind, path]) => [kind, path + suffix]));
78
227
  const scope = "Requires explicit networking:read for reads or networking:manage for changes; full/read defaults do not grant networking. The backend enforces ownership, feature availability and service 2FA locks.";
79
228
  const writes = "Read current capabilities and owned resource state first; obtain approval for the exact change. An accepted operation is pending, not proof of connectivity. Poll the returned operation/resource on its in_progress flag, waiting next_check_seconds between reads: while in_progress is true its reason_code is what is awaited (reason_kind waiting, e.g. a queued or pending reason, or a control plane that has not answered yet), never an error; only in_progress false with reason_kind failure is a failure. Preserve reason_code. On transport uncertainty inspect state and reuse only the original key and unchanged request; never automatically retry.";
80
229
  const paid = "Requires full API access, networking:manage, enabled paid operations, networking:order and spend caps. Choose a supported live catalog tier and its documented request kind; preserve the exact quote; disclose its gross_amount in EUR before user approval. The same Idempotency-Key is required for quote and confirmation. API-key checkout supports the advertised account-balance payment method only. Payment acceptance is not resource readiness.";
230
+ // Keep the strict object for discovery, then check relationships in the shared
231
+ // request builder before either hosted or stdio calls reach the API. Preserve
232
+ // omitted defaults so existing retry keys keep their original request bodies.
233
+ function validateVpnPeerCreation(input) {
234
+ const site = input.kind === "site_to_site";
235
+ if (!site && (input.site !== undefined || input.site_down_alert !== undefined || input.site_reach !== undefined)) {
236
+ throw new Error("Devices must omit site, site_down_alert and site_reach.");
237
+ }
238
+ if (input.grant_scope === "network" && (!Array.isArray(input.grants) || input.grants.length !== 0)) {
239
+ throw new Error("Network-wide access requires grants: [].");
240
+ }
241
+ if (site) {
242
+ (input.protocol === "ipsec_ikev2" ? ipsecSitePeer : sitePeer).parse(input.site);
243
+ }
244
+ }
245
+ function validateVpnPeerGrants(input) {
246
+ if (input.grant_scope === "network") {
247
+ if (input.grants !== undefined && (!Array.isArray(input.grants) || input.grants.length !== 0)) {
248
+ throw new Error("Network-wide access requires omitted or empty grants; the network supplies its servers.");
249
+ }
250
+ }
251
+ else if (input.grants === undefined) {
252
+ throw new Error("Individual-server access requires the complete grants list; omitted grant_scope means servers.");
253
+ }
254
+ }
81
255
  function tool(name, method, path, description, fields = {}, options = {}) {
82
256
  const pathFields = [...path.matchAll(/\{([^}]+)\}/g)].map(match => match[1]);
83
257
  const shape = Object.fromEntries(pathFields.map(field => [field, field === "service_id" ? serviceId : uuid]));
@@ -86,7 +260,7 @@ function tool(name, method, path, description, fields = {}, options = {}) {
86
260
  shape.idempotencyKey = key;
87
261
  else if (options.idempotent) {
88
262
  const pattern = path.includes("/projects/") ? /^[A-Za-z0-9._-]{8,128}$(?![\s\S])/
89
- : path.includes("/public-bindings/") ? /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$(?![\s\S])/ : /^[A-Za-z0-9._:-]{8,128}$(?![\s\S])/;
263
+ : path.includes("/public-bindings/") || path.endsWith("/port-forwards") ? /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$(?![\s\S])/ : /^[A-Za-z0-9._:-]{8,128}$(?![\s\S])/;
90
264
  shape.idempotencyKey = z.string().regex(pattern).describe("Idempotency-Key for this exact change; preserve it and the unchanged body on uncertain recovery");
91
265
  }
92
266
  if (options.paid) {
@@ -99,7 +273,7 @@ function tool(name, method, path, description, fields = {}, options = {}) {
99
273
  }
100
274
  export const networkingTools = [
101
275
  tool("get_network_payment_methods", "POST", "/public/macroPay/methods", "Read payment-method metadata without starting payment. Choose the exact value of an item with system=balance for API-key network purchases; other methods are refused for API keys. This public catalog needs no networking scope.", { country: z.string().regex(/^[A-Za-z]{2}$/).optional() }, { readOnly: true, fixedBody: { includeBalanceMethod: true, includePreinvoiceMethod: false } }),
102
- tool("get_networking_capabilities", "GET", `${N}/capabilities`, "Read account networking availability, quota, creation options and new-server order eligibility. private_network.available/reason_code and order_availability describe selecting an already applied network during a NEW server order; when no such network exists they can be false without denying private-network creation or attachment of existing services. For existing servers, list or create a network and use list_network_attachment_candidates for each service. A refusal from the create, candidate or attach endpoint remains authoritative. Never infer support from tool presence."),
276
+ tool("get_networking_capabilities", "GET", `${N}/capabilities`, "Read account networking availability, quota, creation options and new-server order eligibility. private_network.available/reason_code and order_availability describe selecting an already applied network during a NEW server order; when no such network exists they can be false without denying private-network creation or attachment of existing services. For existing servers, list or create a network and use list_network_attachment_candidates for each service. A refusal from the create, candidate or attach endpoint remains authoritative. Never infer support from tool presence. nat_port_forwarding (available, limit, protocols) gates new NAT port forwarding rules; public_address_server_public, present and true only while an owned public address can be attached directly to a server's public interface, gates attach_public_address_to_server_interface."),
103
277
  tool("get_networking_features", "GET", `${N}/features`, "Read current networking feature gates, DNS options and supported contract extensions."),
104
278
  tool("get_networking_overview", "GET", `${N}/overview`, "Read owned networks, quota and operation snapshot. operations_has_more signals a bounded backend feed."),
105
279
  tool("get_networking_topology", "GET", `${N}/topology`, "Read the account topology with network-scoped identities. Optional metrics=current requests measured traffic.", { metrics: z.literal("current").optional() }, { query: ["metrics"] }),
@@ -107,93 +281,139 @@ export const networkingTools = [
107
281
  tool("get_networking_operation", "GET", `${N}/operations/{operation_id}`, "Read an accepted network or port operation only. Poll on in_progress (true until succeeded, failed or cancelled) and wait next_check_seconds between reads; reason_kind says what reason_code is: waiting (progress, including queued, pending and control-plane-not-yet-answered reasons - not an error), failure or outcome. A port reserve (the operation an attach returns) names its activation_operation_id once the activation that finishes the attachment exists; the completed history line names both ids. A successful network_graph_created operation permits checking network.port_intent_ready for the first attachment; it does not prove guest connectivity. Security-policy, appliance and public-binding operation IDs belong to different stores. Poll security policies with get_network_security_policy, appliances with get_network_appliance_operation, public bindings with get_network_public_binding, and DNS changes through get_private_network.settings.dns_change."),
108
282
  tool("list_private_networks", "GET", `${N}/networks`, "List owned private networks, including deletion awaiting verified cleanup. An empty list is a starting state for creating an approved network, not evidence that existing servers cannot use private networking."),
109
283
  tool("get_private_network", "GET", networks, "Read one owned network including DHCP reservations, DNS change progress, generation and project_id."),
110
- tool("create_private_network", "POST", `${N}/networks`, "Create an approved private network for existing services when no suitable owned network exists. A false new-order private_network.available or order_availability result alone does not block this route; honor this endpoint's own quota and feature refusal. Every subnet is unique on the whole platform and comes from 100.64.0.0/10. Omit subnet and the platform allocates a free block of subnet_prefix_length (28..24, default 26 = 64 addresses; capabilities.private_network.subnet_sizes lists each size with its server count and suggested block); or name an aligned /28 through /24 inside 100.64.0.0/10 that no network holds (409 subnet_in_use), never both. Creation is asynchronous: poll its operation and get_private_network until network.port_intent_ready is true, then check attachment candidates. A successful network_graph_created operation and provisioning_status=configured describe the graph stage; the first guest has not yet qualified connectivity. Never wait for applied_generation to equal generation before the first attachment. Creation does not itself attach a server or buy a public address.", { name, subnet: z.object({ cidr: z.string().regex(/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])(?:\.\d{1,3}){2}\/(?:2[4-8])$/) }).strict().optional(), subnet_prefix_length: z.union([z.literal(24), z.literal(25), z.literal(26), z.literal(27), z.literal(28)]).optional().describe("Size of the allocated subnet when subnet is omitted; default 26"), settings: settings.optional(), default_policy: z.enum(["accept", "deny"]).optional() }, { idempotent: true }),
284
+ tool("create_private_network", "POST", `${N}/networks`, "Create an approved private network for existing services when no suitable owned network exists. A false new-order private_network.available or order_availability result alone does not block this route; honor this endpoint's own quota and feature refusal. Every subnet is unique on the whole platform and comes from 100.64.0.0/10. Omit subnet and the platform allocates a free block of subnet_prefix_length (28..24, default 26 = 64 addresses; capabilities.private_network.subnet_sizes lists each size with its server count and suggested block); or name an aligned /28 through /24 inside 100.64.0.0/10 that no network holds (409 subnet_in_use), never both. Creation is asynchronous: poll its operation and get_private_network until network.port_intent_ready is true, then check attachment candidates. A successful network_graph_created operation and provisioning_status=configured describe the graph stage; the first guest has not yet qualified connectivity. Never wait for applied_generation to equal generation before the first attachment. Creation does not itself attach a server or buy a public address.", { name, subnet: z.object({ cidr: z.string().regex(/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])(?:\.\d{1,3}){2}\/(?:2[4-8])$/) }).strict().optional(), subnet_prefix_length: z.union([z.literal(24), z.literal(25), z.literal(26), z.literal(27), z.literal(28)]).optional().describe("Size of the allocated subnet when subnet is omitted; default 26"), settings: settings.optional(), default_policy: z.enum(["accept", "deny", "ingress_rules"]).optional().describe("Advertised creation policy; omission preserves the backend default. ingress_rules allows IPv4 egress while ingress follows rules/platform exceptions.") }, { idempotent: true }),
111
285
  tool("delete_private_network", "DELETE", networks, "Request deletion of an empty network. Referenced appliances/ports may prevent deletion; quota is released only after verified cleanup.", { expected_generation: generation }, { idempotent: true, destructive: true }),
112
286
  tool("retry_private_network", "POST", `${networks}/retry`, "Retry the failed current network operation only; preserve its current generation. It may resume a failed deletion or settings change, so inspect the failed operation before retrying.", { expected_generation: generation }, { idempotent: true, destructive: true }),
113
287
  tool("set_private_network_dns", "POST", `${networks}/dns`, "Replace the network's DHCP DNS mode and resolver addresses; changing or disabling them can interrupt name resolution for attached guests. Use dns_servers only for custom mode. Poll get_private_network.settings.dns_change; its DNS update UUID is not in the network operations feed.", { dns_mode: dnsMode, dns_servers: z.array(ipv4).min(1).max(3).optional() }, { idempotent: true, destructive: true }),
114
288
  tool("set_private_network_names", "PUT", `${networks}/names`, "Replace the complete internal DNS zone label and alias list. Server names are automatic; these names resolve only inside this network. Omitted aliases are removed and may stop resolving.", { zone_label: dnsLabel, aliases: z.array(dnsAlias).max(64) }, { destructive: true }),
115
289
  tool("list_private_network_ports", "GET", `${networks}/ports`, "Read owned network ports, their fixed DHCP addresses, generations and attachment states. Reserved is not connected."),
116
290
  tool("list_service_private_ports", "GET", `${N}/services/{service_id}/ports`, "Read private ports and order attachment recovery state for one owned service order number."),
291
+ tool("get_service_public_firewall", "GET", firewall, `Read the firewall of an owned server's public interface (VPS or Cloud VPS; incoming allow rules for IPv4 and IPv6). Free. available and unavailable_reason say whether it is offered for this server now; enabled, rules, generation (send it as expected_generation to set_service_public_firewall; 0 = the server never had one), applied_generation and state: applying, applied (the node enforces it), saved_for_start (stored, loaded before the server's interface comes up), not_applied with reason_code (public_firewall_closed = the node closed ALL incoming connections; apply_service_public_firewall sends the saved rules again), not_enforced (the node's bypass is on, nothing is filtered) or off. limits.rules is the rule limit; manage and manage_reason (read_only_key, two_factor_locked, suspended, check_failed) say whether this key may change it now; management_ports names the SSH port (RDP on a Windows Cloud VPS) that a change must keep open so the user is not locked out. ${firewallCaller}`, {}, {}),
292
+ tool("set_service_public_firewall", "PUT", firewall, `Replace the COMPLETE firewall of an owned server's public interface (VPS or Cloud VPS) at the generation read with get_service_public_firewall: enabled switches it on or off, rules is every incoming allow rule (at most limits.rules; a rule left out is removed). Free. Switched on, a new connection reaches the server only where a rule allows it (replies to connections the server opened, ICMP errors, neighbour discovery and DHCP still pass; outgoing traffic stays allowed); removing a rule refuses new connections at once but does not cut a session already open. Before switching it on or removing a rule, make sure management_ports stays open for the user's own address, and ask the user for that address. Switching on with no incoming rule needs acknowledge_no_incoming=true (every new connection is then refused). Outgoing rules are not offered. Refusals: 409 public_firewall_generation_conflict (read again), 409 public_firewall_unavailable with unavailable_reason (switching off is always accepted), 422 public_firewall_rule_limit, 422 with field rules.N.<key> for a refused rule, 422 public_firewall_idempotency_key_reused, 423 while the server's two-factor lock is closed. A body equal to the stored one answers the current state and changes nothing. The answer reads state applying; poll get_service_public_firewall until state is applied (saved_for_start for a stopped server) or not_applied. ${firewallCaller}`, { expected_generation: policyGeneration, enabled: z.boolean(), rules: z.array(firewallRule).max(100), acknowledge_no_incoming: z.boolean().optional() }, { idempotent: true, destructive: true }),
293
+ tool("apply_service_public_firewall", "POST", `${firewall}/apply`, `Send the saved firewall of an owned server to its node again, typically after state not_applied; the rules and the generation do not change. Free. A send still waiting for the node's answer is not repeated; a server without a firewall, or with it switched off and confirmed, answers its current state and nothing is sent. Poll get_service_public_firewall for the state. ${firewallCaller}`, {}, { destructive: true, bodyless: true }),
117
294
  tool("list_network_attachment_candidates", "GET", `${networks}/attachment-candidates`, "For a network with port_intent_ready=true, check each existing service's attachment eligibility and attachment_mode; service_id selects one owned service. Follow next_cursor using after while candidates_has_more is true. Use this per-service result and its reason_code for existing-VM attachment, not new-server order_availability. Do not attach a service the backend marks ineligible.", { after: z.string().optional(), service_id: serviceId.optional() }, { query: ["after", "service_id"] }),
118
- tool("attach_private_network_port", "POST", `${networks}/ports`, "Attach an eligible existing owned service to a private network with port_intent_ready=true; omit ipv4 for automatic allocation or pass a fixed usable address. Check list_network_attachment_candidates for this network and service first, including pagination and attachment_mode. Set restart=true only after the user approves a required restart. An accepted attach is pending: it returns the port's reserve operation (queued with private_port_apply_pending; reserve, activate and detach take 13-81 s through the host) - poll it with get_networking_operation on in_progress, read its activation_operation_id once the activation that finishes the attachment exists, and verify the final port and service attachment state. Honor this endpoint's own refusal and reason_code.", { service_id: serviceId, ipv4: ipv4.optional(), restart }, { idempotent: true }),
295
+ tool("attach_private_network_port", "POST", `${networks}/ports`, "Attach an eligible existing owned service to a private network with port_intent_ready=true; omit ipv4 for automatic allocation or pass a fixed usable address. Check list_network_attachment_candidates for this network and service first, including pagination and attachment_mode. Set restart=true only after the user approves a required restart. An accepted attach is pending: it returns the port's reserve operation (queued with private_port_apply_pending; do not assume a duration) - poll it with get_networking_operation on in_progress, read its activation_operation_id once the activation that finishes the attachment exists, and verify the final port and service attachment state. Honor this endpoint's own refusal and reason_code.", { service_id: serviceId, ipv4: ipv4.optional(), restart }, { idempotent: true }),
119
296
  tool("detach_private_network_port", "DELETE", `${networks}/ports/{port_id}`, "Detach the owned server port. Use the PORT generation and confirm any required restart.", { expected_generation: generation, restart }, { idempotent: true, destructive: true }),
120
297
  tool("retry_private_network_port", "POST", `${networks}/ports/{port_id}/retry`, "Retry the failed current port operation with the PORT generation and any required restart approval. Inspect the failed operation: a detach retry can remove the server's private connectivity.", { expected_generation: generation, restart }, { idempotent: true, destructive: true }),
121
- tool("get_network_vpn_summary", "GET", `${networks}/vpn`, "Read the network's VPN summary, including truthful absence. Native site-to-site is not supported by this tool set."),
298
+ tool("get_network_vpn_summary", "GET", `${networks}/vpn`, "Read the network's VPN summary, including truthful absence. Connected locations (site-to-site) are managed on the VPN gateway: create_network_vpn_peer with kind=site_to_site, set_network_vpn_site_options, set_network_vpn_site_alert and set_network_site_route_mode."),
122
299
  tool("get_network_routed_egress", "GET", `${networks}/routed-egress`, "Read this network's NAT/internet access, public address and qualification state."),
123
300
  tool("set_network_routed_egress", "POST", `${networks}/routed-egress`, "Request the currently supported NAT transition for an owned network; the backend may refuse mode changes. This can replace the network's Internet route. public_address_id attaches an already-owned paid address and may remove it from a server. This never purchases an address.", { internet_access: internetAccess, expected_generation: generation, public_address_id: uuid.optional() }, { destructive: true }),
124
- tool("get_security_group_capabilities", "GET", `${N}/security-groups/capabilities`, "Read security-group availability and whether the network default policy can be changed."),
301
+ tool("get_network_port_forwards", "GET", `${networks}/port-forwards`, "Read the NAT port forwarding rules of an owned private network: applied rules (protocol, external_port on the network's NAT public address, the target server's private_port_id and its internal_port), the router generation to send to set_network_port_forwards (not the network generation), state (empty, pending, active, failed), per-rule exposure (open_to_internet is true when the server's security groups admit that port from every IPv4 address), targets with eligible, unavailable_reason_code, kind (private_primary = a server without its own public IPv4, own_ipv4 = the private interface of a server that has one) and reply_route (own_ipv4 only), capability (available, limit, protocols), last_change (a refused change with its nat_port_forward_refused_* reason_code) and suspended (rules kept while the network has no NAT address; they are on no router). active is the router's committed state, not a packet test. The network needs its NAT public address (see get_network_routed_egress); capability.available false means new rules are not accepted for this account now, rules already there can still be removed."),
302
+ tool("set_network_port_forwards", "PUT", `${networks}/port-forwards`, "Replace the COMPLETE NAT port forwarding rule set of an owned network (rules: [] withdraws all of them) at the router generation read from get_network_port_forwards. Each rule forwards TCP or UDP from the network's NAT public address (external_port) to a server's private port (private_port_id, internal_port). Targets are ports of this network's Firecracker servers that get_network_port_forwards lists as eligible: the first interface of a server without a public IPv4 of its own (kind private_primary) or the private interface of a server that has its own public IPv4 (kind own_ipv4; it answers forwarded connections through the network, and the target's reply_route says whether that reply route is in place or which command the customer runs on the server). Cloud VPS servers are not accepted. Security groups still decide whether the server takes the traffic: this never opens a security group and never buys an address. Mail ports 25, 465, 587 and 2525 are refused for every rule (nat_port_forward_smtp_refused) and a new or changed rule on a port the platform refuses is refused with nat_port_forward_port_refused; a refusal of one rule names it in context.rule_index. At most capability.limit rules (64 at most). A set equal to the applied one answers 200 outcome unchanged; otherwise 202, with outcome queued when an earlier change is still being applied and retried when the identical failed set is sent again. A request that only removes rules is never stopped by a service two-factor or abuse lock. Poll get_network_port_forwards until applied_generation equals generation and state is active or empty; last_change names a change the router refused (the applied rules are unchanged then).", { expected_generation: generation, rules: z.array(portForwardRule).max(64) }, { idempotent: true, destructive: true }),
303
+ tool("get_security_group_capabilities", "GET", `${N}/security-groups/capabilities`, "Read security-group availability, whether the network default policy can be changed, the offered default_policy_values (accept, deny, ingress_rules) and whether create-group egress_policy is accepted."),
125
304
  tool("list_network_security_groups", "GET", sg, "List groups in the owned project. Copy project_id from the network; do not invent an account identifier."),
126
- tool("create_network_security_group", "POST", sg, "Create a named allow-rule set. If no egress rule is supplied, the backend adds an egress-any allow rule; updates instead keep exactly the rules supplied. Optional network_id scopes it to that network; otherwise it belongs to the project. IPv6 rules are unavailable.", { name, rules: z.array(rule).max(256), network_id: uuid.optional() }, { idempotent: true }),
305
+ tool("create_network_security_group", "POST", sg, "Create a named allow-rule set. When capabilities advertise egress_policy, rules_only preserves exactly the supplied rules and allow adds unrestricted IPv4 egress. Omit egress_policy for legacy behavior: the backend adds egress-any only if no egress rule was supplied. Updates and clones preserve exact rules. Optional network_id scopes the group to that network; otherwise it belongs to the project. IPv6 rules are unavailable.", { name, rules: z.array(rule).max(256), network_id: uuid.optional(), egress_policy: z.enum(["allow", "rules_only"]).optional() }, { idempotent: true }),
127
306
  tool("update_network_security_group", "PUT", `${sg}/{group_id}`, "Replace the complete group name and rule list at its expected revision. Bound servers may be affected; empty rules deny traffic allowed only by this group. Poll get_network_security_policy for each affected network (policies[].network_id); these receipts are not generic network operation IDs.", { name, rules: z.array(rule).max(256), expected_revision: generation }, { idempotent: true, destructive: true }),
128
307
  tool("delete_network_security_group", "DELETE", `${sg}/{group_id}`, "Delete an unreferenced customer group at its expected revision; referenced groups are refused.", { expected_revision: generation }, { idempotent: true, destructive: true }),
129
308
  tool("clone_network_security_group", "POST", `${sg}/{group_id}/clone`, "Clone an owned group into a new name; use the source group's current revision.", { name, expected_revision: generation }, { idempotent: true }),
130
309
  tool("get_network_security_policy", "GET", policy, "Read default_policy, assigned group rules and desired/applied policy generations. Policy generation differs from network and port generations. effective_policy.default_ingress/default_egress describe implicit deny fallthrough after allow rules; they do not override default_policy=accept or mean every packet is denied. Custom group rules replace the network default on their assigned ports."),
131
- tool("set_network_default_security_policy", "PUT", policy, "Change accept/deny for servers without custom groups. Explicit custom groups remain restrictive. Use policy generation and default-group revision from get_network_security_policy. Poll get_network_security_policy for each affected network (policies[].network_id); these receipts are not generic network operation IDs.", { default_policy: z.enum(["accept", "deny"]), expected_generation: policyGeneration, expected_default_revision: generation }, { idempotent: true, destructive: true }),
310
+ tool("set_network_default_security_policy", "PUT", policy, "Change the policy for servers without custom groups using an advertised default_policy_values value: accept, deny, or ingress_rules (allow unrestricted IPv4 egress while ingress follows rules/platform exceptions). Explicit custom groups remain restrictive. Use policy generation and default-group revision from get_network_security_policy. Poll get_network_security_policy for each affected network (policies[].network_id); these receipts are not generic network operation IDs.", { default_policy: z.enum(["accept", "deny", "ingress_rules"]), expected_generation: policyGeneration, expected_default_revision: generation }, { idempotent: true, destructive: true }),
132
311
  tool("set_private_port_security_groups", "PUT", `${project}/networks/{network_id}/ports/{port_id}/security-groups`, "Replace the port's complete customer-group list. Empty list selects the network default; at most seven customer groups fit alongside platform DNS. Use policy and port generations separately. Poll get_network_security_policy for each affected network (policies[].network_id); these receipts are not generic network operation IDs.", { group_ids: z.array(uuid).max(7), expected_generation: policyGeneration, expected_port_generation: generation }, { idempotent: true, destructive: true }),
133
312
  tool("retry_network_security_policy", "POST", `${policy}/retry`, "Retry the failed network security policy using its current desired state. The pending policy may replace firewall rules and remove network access; inspect its desired state first. Poll get_network_security_policy for each affected network (policies[].network_id); these receipts are not generic network operation IDs.", {}, { idempotent: true, destructive: true }),
134
313
  tool("retire_network_security_policy", "DELETE", policy, "Retire the policy only after its server ports are gone. Removing firewall policy requires explicit user approval. Poll get_network_security_policy for each affected network (policies[].network_id); these receipts are not generic network operation IDs.", { expected_generation: generation }, { idempotent: true, destructive: true }),
135
- tool("get_network_appliance_capabilities", "GET", `${A}/capabilities`, "Read available load balancer providers/tiers, public qualification, VPN capabilities and quota. Tier availability and prices are returned by the backend, never hardcoded."),
314
+ tool("get_network_appliance_capabilities", "GET", `${A}/capabilities`, "Read available load balancer providers/tiers, public qualification, VPN capabilities (including technologies and ipsec_vendors) and quota. Tier availability and prices are returned by the backend, never hardcoded."),
136
315
  tool("list_network_appliances", "GET", A, "Read owned load balancers and VPN gateways with one stream cursor."),
137
316
  tool("get_network_appliance_billing", "GET", `${A}/billing`, "Read current customer-visible recurring appliance lines and currency."),
138
- tool("get_network_appliance_operation", "GET", `${A}/operations/{operation_id}`, "Poll an accepted appliance operation on in_progress (true until succeeded, failed or cancelled), waiting next_check_seconds between reads (a load balancer or VPN change settles in a few seconds). A fresh change is queued with appliance_dispatch_pending and then waiting_dependency while the control plane answers; reason_kind waiting marks every such reason as progress, not an error. Creation succeeds only when its actual resource state and health confirm readiness."),
317
+ tool("get_network_appliance_operation", "GET", `${A}/operations/{operation_id}`, "Poll an accepted appliance operation on in_progress (true until terminal), waiting next_check_seconds between reads when supplied; that interval is a poll suggestion, not a completion estimate. A fresh change is queued with appliance_dispatch_pending and then waiting_dependency while the control plane answers; reason_kind waiting marks every such reason as progress, not an error. For VPN operations, started_at is immutable acceptance time; last_progress_at is durable action progress; last_observed_at is a changed agent heartbeat at the action generation; progress_stale describes age of action progress, not heartbeat or tunnel health. stage labels progress, while outcome and finished_at are non-null only at a terminal state. An API receipt, observed heartbeat, selected FRR route or terminal success does not prove connectivity. Check resource state and health separately."),
139
318
  tool("list_network_load_balancers", "GET", `${A}/load-balancers`, "List private and public load balancers and customer-safe health/qualification state."),
140
319
  tool("get_network_load_balancer", "GET", lb, "Read one owned load balancer, listeners, members, generation and health. A pending or unqualified public endpoint is not ready."),
141
320
  tool("estimate_network_load_balancer", "POST", `${A}/load-balancers/quote`, "Read a technical load balancer estimate for this exact configuration. This does not issue a paid purchase token; buy through quote_network_product and order_network_product. Legacy POST /appliances/load-balancers refuses unpaid creation.", loadBalancerSpec, { readOnly: true }),
142
321
  tool("delete_network_load_balancer", "DELETE", lb, "Request appliance teardown at its current generation; this is not a refund or paid-period cancellation. Addresses are released only after verified cleanup.", { expected_generation: generation }, { idempotent: true, destructive: true }),
143
322
  tool("add_network_load_balancer_member", "POST", `${lb}/members`, "Add an attached private server address to the selected listener; backend validates membership in this network.", { listener_id: uuid, address: ipv4, port }, { idempotent: true }),
144
323
  tool("remove_network_load_balancer_member", "DELETE", `${lb}/members/{member_id}`, "Remove a member from rotation using the load balancer generation.", { expected_generation: generation }, { idempotent: true, destructive: true }),
145
- tool("add_network_load_balancer_listener", "POST", `${lb}/listeners`, "Add a listener and optional initial members using currently offered protocol capabilities. SMTP ports may be refused.", { protocol, port, members: z.array(member).max(64).optional() }, { idempotent: true }),
324
+ tool("add_network_load_balancer_listener", "POST", `${lb}/listeners`, "Add a listener and optional initial members using currently offered protocol capabilities. An https listener passes the encrypted connection through to the members, which hold the certificate; the load balancer does not terminate TLS. SMTP ports may be refused.", { protocol, port, members: z.array(member).max(64).optional() }, { idempotent: true }),
146
325
  tool("remove_network_load_balancer_listener", "DELETE", `${lb}/listeners/{listener_id}`, "Remove a listener and its members; deleting the last listener is refused.", { expected_generation: generation }, { idempotent: true, destructive: true }),
326
+ tool("rebuild_network_load_balancer", "POST", `${lb}/rebuild`, "Rebuild every proxy server of an owned public load balancer, one at a time, from the image it runs now; the others keep serving, so the balancer keeps its address, listeners and members. Free. Proxies that do not serve are rebuilt first; a healthy balancer may be rebuilt too. The operation (kind load_balancer_proxy_rebuild) waits with reason load_balancer_rebuilding until every proxy is rebuilt, or fails with load_balancer_rebuild_failed at the first proxy that could not be rebuilt (the platform rebuilds that one by itself), or is cancelled when the balancer is removed or its paid period ends. Refusals: 409 load_balancer_rebuild_not_offered (an internal balancer has no server of its own), 409 load_balancer_not_active, 409 appliance_busy, 409 load_balancer_proxy_paid_period_unavailable, 429 load_balancer_rebuild_too_soon (one rebuild per two minutes), 423 while a server of its network is suspended for abuse. Every read of the balancer shows a running rebuild in its rebuild field.", {}, { idempotent: true, destructive: true }),
147
327
  tool("list_network_vpn_gateways", "GET", `${A}/vpn-gateways`, "List owned VPN gateways, their device peers and safe status. No private keys or one-time configurations are retrieved."),
148
- tool("get_network_vpn_gateway", "GET", vpn, "Read one owned VPN gateway, current generation, device peers, grant candidates and attachment capability."),
328
+ tool("get_network_vpn_gateway", "GET", vpn, "Read one owned VPN gateway, current generation, peers, grant candidates and attachment capability. recent_operation is the latest gateway action summary, with durable progress and generation-matched observation timestamps; it is not a connectivity test. image.update.public_release is optional operator-reviewed channel and notes for a qualified selected target when update status is current or available; it does not describe the installed build or promise an automatic update. Read capabilities.site_ipsec and ipsec_profiles before requesting an IPsec site; availability depends on this gateway's advertised capabilities. capabilities.site_ipsec_bgp_lines reports available/reason_code when the current backend can support two-line BGP; it is absent on older backends. Peer grant_scope distinguishes individual servers from network-wide access to current and future eligible servers. WireGuard peers carry rx_bytes / tx_bytes, last_handshake_at and current_endpoint (the outer address:port last observed; live only, null until learned). IPsec sites report SA/liveness and crypto facts: an old CHILD-SA rekey time is not a failed WireGuard handshake, and requested pqc is not observed post_quantum. For two-line sites, site.lines[n].bgp and site.lines[n].active belong to that exact line; site.bgp is legacy line-1 data. A line's active is local route ownership, not a BGP bestpath. When present, route_visibility is VPSnet gateway FRR evidence scoped to that line: accepted and selected are bounded neighbor routes, advertised is after-policy output, and installed.observed proves only exact local kernel routes. Unknown or stale evidence is not an empty route assertion; no customer-router reachability is measured. site_options shows hub_and_spoke, hub_and_spoke_applied and route_mode while site-to-site is enabled. For traffic over time use get_network_vpn_peer_history."),
329
+ tool("rename_network_vpn_gateway", "PUT", `${vpn}/name`, "Rename an owned VPN's customer label. The backend checks label uniqueness; this does not change the tunnel or guest generation.", { name: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,79}$/) }),
149
330
  tool("delete_network_vpn_gateway", "DELETE", vpn, "Request teardown of an owned VPN gateway; all devices lose connectivity. Paid time is not refunded by this operation.", { expected_generation: generation }, { idempotent: true, destructive: true }),
150
331
  tool("attach_network_vpn_gateway", "POST", `${vpn}/attach`, "Attach or move an account VPN to an owned network only when standalone attachment is advertised. This recreates the gateway and interrupts connections; confirm the interruption.", { network_id: uuid }, { idempotent: true, destructive: true }),
151
332
  tool("detach_network_vpn_gateway", "POST", `${vpn}/detach`, "Detach an account VPN from its network when supported; it keeps its paid period and devices but recreates the gateway. Confirm the interruption.", {}, { idempotent: true, destructive: true }),
152
333
  tool("set_vpn_server_access", "POST", `${vpn}/server-access`, "Choose accept or groups_only for the VPN's access to attached servers when this capability is available. The setting operation can already be succeeded while security-policy application is pending. Read the gateway network with get_private_network, then get_network_security_policy using its project_id and network_id; do not treat the setting operation as proof of firewall delivery.", { mode: z.enum(["accept", "groups_only"]) }, { idempotent: true, destructive: true }),
153
- tool("create_network_vpn_peer", "POST", `${vpn}/peers`, "Add a device with an externally generated one-time PUBLIC reveal key. Pass 0-16 owned grant-candidate IPv4 addresses: split-tunnel VPNs require at least one; full-tunnel VPNs may use an empty list. Keep the matching private key entirely outside MCP. The gateway generates the WireGuard key; wait for config_available before consuming its sealed configuration.", { name: name.max(64), grants: z.array(ipv4).max(16), reveal_public_key: publicKey }, { idempotent: true }),
154
- tool("take_network_vpn_peer_config", "GET", `${peer}/config`, "Consume the device's sealed configuration ONCE. This is a management mutation despite GET. The ciphertext is decryptable only by the customer's matching external reveal private key; this tool never creates, requests or decrypts private keys. Save the returned sealed payload externally; a second pickup is 410. Never retry automatically after an uncertain transport result.", { acknowledge_one_time_pickup: z.literal(true) }, { readOnly: false, destructive: true }),
334
+ tool("restart_network_vpn_gateway", "POST", `${vpn}/restart`, "Restart the owned VPN VM in place. Connections are interrupted; inspect the returned operation and gateway. Backend spacing and busy refusals are authoritative.", {}, { idempotent: true, destructive: true }),
335
+ tool("rebuild_network_vpn_gateway", "POST", `${vpn}/rebuild`, "Rebuild the owned VPN VM with its existing address and devices. This interrupts connections for roughly one to two minutes; inspect the operation and gateway.", {}, { idempotent: true, destructive: true }),
336
+ tool("update_network_vpn_gateway_image", "POST", `${vpn}/image-update`, "Apply the backend-qualified reviewed VPN image in place. Read get_network_vpn_gateway image.update first and approve the reboot interruption. Poll the operation and gateway; acceptance does not prove the new boot or connectivity. No caller-selected image or release is accepted.", {}, { idempotent: true, destructive: true }),
337
+ tool("check_network_vpn_public_access", "POST", `${vpn}/public-access/check`, "Preview the complete VPN public-source lists without applying them. Read get_network_vpn_gateway public_access first. The backend reports affected connected clients; its own-address check sees the MCP gateway's API source, not the customer's computer. Omitted services and empty lists clear customer restrictions for that service; automatic fixed-site restrictions still apply.", { services: publicAccessServices }, { readOnly: true }),
338
+ tool("set_network_vpn_public_access", "PUT", `${vpn}/public-access`, "Replace the complete WireGuard/IPsec public-source lists at the VPN gateway generation. Preview the same services with check_network_vpn_public_access first. Omitted services and empty lists remove customer restrictions, potentially expanding access. Only set acknowledge_disconnect=true after approval to disconnect affected clients. Poll gateway public_access for actual applied evidence.", { services: publicAccessServices, expected_generation: generation, acknowledge_disconnect: z.literal(true).optional() }, { idempotent: true, destructive: true }),
339
+ tool("set_network_vpn_internet_exit", "PUT", `${vpn}/internet-exit`, "Choose none, the VPN's own IPv4, or its attached network NAT as Internet exit. The backend qualifies each mode and returns applying/applied state. Reusing the same idempotency key with the same mode is safe after uncertainty.", { mode: z.enum(["none", "own_ipv4", "network_nat"]) }, { idempotent: true, destructive: true }),
340
+ tool("create_network_vpn_peer", "POST", `${vpn}/peers`, "Add a device or, with kind=site_to_site, a connected location using a customer-generated one-time X25519 PUBLIC reveal key. Protocol defaults to wireguard; ipsec_ikev2 also supports built-in operating-system device clients when gateway capabilities.device_ikev2 advertises availability. Devices must omit site, site_down_alert and site_reach. Pass 0-16 explicit server grants, or grant_scope=network with grants=[] for all eligible current and future servers; approve broader access. Empty explicit grants gives no network-server access; backend checks whether anything remains reachable. Sites require protocol-specific site settings: WireGuard prefixes/endpoint/keepalive/router_model; IPsec remote_address/vendor, optional bgp/tuning and mutually exclusive pqc/legacy_compat. IPsec prefixes may be empty or omitted only with bgp.accept=any_private. Optional site_reach overrides the VPN default (null inherits). Read current gateway and appliance capabilities before choosing supported protocols, crypto and vendors. Keep the matching reveal private key entirely outside MCP. Wait for config_available and consume the sealed configuration once; decrypt only outside MCP. IPsec device passwords and site PSKs must never enter MCP. Router recipes contain placeholders only.", { name: name.max(64), grants: z.array(ipv4).max(16), reveal_public_key: publicKey, kind: z.enum(["road_warrior", "site_to_site"]).optional(), protocol: z.enum(["wireguard", "ipsec_ikev2"]).optional(), grant_scope: grantScope.optional(), site: z.union([sitePeer, ipsecSitePeer]).optional(), site_reach: reach.nullable().optional(), site_down_alert: z.boolean().optional() }, { idempotent: true, validate: validateVpnPeerCreation }),
341
+ tool("take_network_vpn_peer_config", "GET", `${peer}/config`, "Consume a device's or site's sealed configuration ONCE. For IPsec primary-key rotation pass line=1 explicitly: without it, a pending second-line envelope can be returned after the primary was consumed. This is a management mutation despite GET. The ciphertext is decryptable only by the customer's matching external reveal private key; this tool never creates, requests or decrypts private keys or a raw PSK. A second pickup is 410. Never retry automatically after an uncertain transport result.", { acknowledge_one_time_pickup: z.literal(true), line: z.literal(1).optional() }, { readOnly: false, destructive: true, query: ["line"] }),
342
+ tool("take_network_vpn_site_line_config", "GET", `${peer}/config`, "Consume the second IPsec line's sealed key exactly once with line=2. Decrypt only outside MCP with the private key matching add_network_vpn_site_line's public reveal key. A second pickup is 410; never retry after an uncertain response.", { acknowledge_one_time_pickup: z.literal(true) }, { readOnly: false, destructive: true, fixedQuery: { line: "2" } }),
343
+ tool("rotate_network_vpn_ipsec_key", "POST", `${peer}/rotate-key`, "Rotate an IPsec site's pre-shared key. Supply a fresh customer-generated X25519 public reveal key, keep its private half outside MCP, then consume the new sealed material once with take_network_vpn_peer_config line=1. The old key stops authenticating new sessions after apply; inspect the operation. WireGuard peers are refused.", { reveal_public_key: publicKey }, { idempotent: true, destructive: true }),
155
344
  tool("revoke_network_vpn_peer", "DELETE", peer, "Revoke one device using the VPN GATEWAY generation. Its tunnel access ends after the next applied configuration.", { expected_generation: generation }, { idempotent: true, destructive: true }),
156
- tool("set_network_vpn_peer_grants", "PUT", `${peer}/grants`, "Replace the device's complete reachable-server list when standalone VPN is available. Empty list removes server grants; use gateway generation. Check routes_update on the resulting peer for changes the customer must make to AllowedIPs.", { expected_generation: generation, grants: z.array(ipv4).max(16) }, { idempotent: true, destructive: true }),
345
+ tool("set_network_vpn_peer_grants", "PUT", `${peer}/grants`, "Replace the peer's complete server-access choice using the gateway generation. Read the gateway and current grant_scope first. Omitted grant_scope means servers: supply the full 0-16 address list; this also disables a device's previous network-wide access to future servers, even if today's list is unchanged. Empty grants removes this peer's server access. Devices and sites support grant_scope=network: omit grants or send [] to allow every eligible current and future server of the network, subject to network security policy. Confirm that scope and its effects. Check routes_update for required customer router/client changes.", { expected_generation: generation, grants: z.array(ipv4).max(16).optional(), grant_scope: grantScope.optional() }, { idempotent: true, destructive: true, validate: validateVpnPeerGrants }),
346
+ tool("get_network_vpn_peer_history", "GET", `${peer}/history`, "Read one device's or site's traffic and connection history, oldest point first: range 24h = raw 30-second samples (kept 24 h), 7d = 10-minute points and 30d = hourly points (from per-minute rows kept 30 days), 13m = daily UTC points (from hourly rows kept 13 months). rx = bytes the VPN received from the peer, tx = bytes sent to it. For WireGuard, connected means a handshake at most 180 s old in the step. For IPsec, connected means the SA was up at the sample; its CHILD-SA rekey timestamp is not the WireGuard liveness timer. A period the VPN did not observe has no point (unknown, never 0). The endpoint IP is live only on get_network_vpn_gateway and never part of the history.", { range: z.enum(["24h", "7d", "30d", "13m"]) }, { query: ["range"] }),
347
+ tool("list_network_vpn_site_health_webhooks", "GET", `${peer}/health-webhooks`, "List this owned site's signed health subscriptions and last delivery outcomes. Destination URLs are shown as public HTTPS hosts only; stored paths and signing material are never returned. Health is the VPN-side observation, not a test from the customer's LAN."),
348
+ tool("create_network_vpn_site_health_webhook", "POST", `${peer}/health-webhooks`, "Create a signed site health subscription. Supply a public HTTPS DNS endpoint on port 443 and a caller-generated X25519 PUBLIC reveal key; keep the private half outside MCP. The immediate 201 response contains the subscription and a one-time sealed_secret envelope, not a raw signing secret. Decrypt it outside MCP to configure the receiver. Verify timestamp, exact-body HMAC and event-ID deduplication at the receiver. After an uncertain outcome, list subscriptions before deciding whether to create another; this route has no idempotency key or operation ID.", { url: webhookUrl, reveal_public_key: publicKey }, {}),
349
+ tool("delete_network_vpn_site_health_webhook", "DELETE", `${peer}/health-webhooks/{subscription_id}`, "Remove this owned site's subscription and cancel pending deliveries. The immediate 200 result is {removed:true}; a missing subscription returns 404. Check the list after an uncertain outcome; this route has no operation ID.", {}, { destructive: true, bodyless: true }),
350
+ tool("get_network_vpn_peer_recipe", "GET", `${peer}/recipe`, "Read backend-sourced router setup instructions for one owned IPsec IKEv2 site. Optional vendor, language, line selection and version/inputs preview use the backend recipe catalogue. The recipe contains a literal {{PSK}} placeholder, never the secret. Unsupported router/BGP/line combinations are refused, not replaced by a static recipe.", { vendor: ipsecVendor.optional(), lang: z.enum(["en", "lt"]).optional(), lines: z.enum(["all", "2"]).optional(), version: z.string().max(64).optional(), inputs: recipeInputs.optional() }, { query: ["vendor", "lang", "lines", "version", "inputs"] }),
351
+ tool("save_network_vpn_router_setup", "PUT", `${peer}/router-setup`, "Save nonsecret backend-catalogue router version and local interface/zone fields for an IPsec site, optionally line 2. This changes saved setup guidance, not the tunnel. The backend validates vendor, version and fields.", { vendor: ipsecVendor, line: z.union([z.literal(1), z.literal(2)]).optional(), version: z.string().max(64).nullable().optional(), inputs: routerInputs.optional() }),
352
+ tool("set_network_vpn_site_bgp", "PUT", `${peer}/bgp`, "Enable or change BGP over an eligible IPsec site using the gateway generation. For two lines, the backend requires listed prefixes and a qualified route-based line 2 with current agent capability; preserve its typed 409 vpn_ipsec_lines_bgp if unavailable. Set enabled=false to turn BGP off; when switching from any_private, supply remote_prefixes for static routing. Follow each line's bgp and active facts separately; no operation ID is promised.", bgpFields, { destructive: true, validate: validateBgp }),
353
+ tool("add_network_vpn_site_line", "POST", `${peer}/lines`, "Add line 2 to an IPsec site using the gateway generation and an external X25519 public reveal key. Keep its private key outside MCP; after config_available, take_network_vpn_site_line_config returns its sealed key exactly once. For two-line BGP, supply a supported route-based vendor and let the backend infer independent setup; no independent_setup request field exists. Preserve the backend's typed 409 vpn_ipsec_lines_bgp when the current capability is unavailable.", { expected_generation: generation, reveal_public_key: publicKey, remote_address: ipv4.nullable().optional(), vendor: ipsecVendor.nullable().optional(), dpd_seconds: z.number().int().min(10).max(300).optional() }, { idempotent: true, destructive: true }),
354
+ tool("set_network_vpn_site_main_line", "PUT", `${peer}/lines/main`, "Choose line 1 or 2 as the main IPsec line using the gateway generation; optional DPD applies to both. Confirm routing interruption and inspect the returned peer/operation.", { line: z.union([z.literal(1), z.literal(2)]), expected_generation: generation, dpd_seconds: z.number().int().min(10).max(300).nullable().optional() }, { idempotent: true, destructive: true }),
355
+ tool("remove_network_vpn_site_second_line", "DELETE", `${peer}/lines/2`, "Remove line 2 using the gateway generation. The backend refuses removal while it is main; move main to line 1 first. Inspect the returned operation and peer.", { expected_generation: generation }, { idempotent: true, destructive: true }),
356
+ tool("set_network_vpn_ipsec_tuning", "PUT", `${peer}/ipsec-tuning`, "Replace an IPsec site's advanced tunnel choices using the gateway generation. Read gateway ipsec_tuning for currently offered values and the site's crypto profile; pinned crypto conflicts with PQC or legacy compatibility. tuning=null or an empty object restores automatic defaults; omitted members also become automatic. The numeric bounds in the schema are the platform's outer limits, not the offered set: the gateway's ipsec_tuning lists the key exchange groups, suites and lifetime ranges offered now, and the backend refuses others with vpn_ipsec_tuning_not_allowed. Changes can interrupt negotiation. Poll the operation and gateway until tuning_applied is true; never supply a PSK or private key.", { expected_generation: generation, tuning: ipsecTuning }, { idempotent: true, destructive: true }),
357
+ tool("start_network_vpn_tunnel_test", "POST", `${peer}/tests`, "Start a backend tunnel test for a site or supported WireGuard peer; optional line selects 1 or 2. A running test may be returned instead of a new probe. Read the returned test ID with get_network_vpn_tunnel_test; rate and capability refusals are authoritative.", { line: z.union([z.literal(1), z.literal(2)]).optional() }, { idempotent: true }),
358
+ tool("get_network_vpn_tunnel_test", "GET", `${peer}/tests/{test_id}`, "Read one owned tunnel test by its returned ID; while running, poll no faster than the backend's one-second guidance."),
359
+ tool("set_network_vpn_site_reach", "PUT", `${peer}/site-reach`, "Set this site's load-balancer/floating-IP reach choice, or null to follow its VPN default. This can expand access to services behind those addresses; the backend checks ownership and service locks. Inspect site_reach.state and reason_code.", { reach: reach.nullable() }, { destructive: true }),
360
+ tool("set_network_vpn_site_options", "PUT", `${vpn}/site-options`, "Set one or more VPN options: hub_and_spoke joins offices, site_reach sets the default load-balancer/floating-IP reach for sites, and device_mesh lets devices reach each other. Each can expand connectivity. The backend checks attached service locks and reports applied state; read get_network_vpn_gateway until it settles. 503 vpn_site_routing_unavailable while site routing is disabled.", { hub_and_spoke: z.boolean().optional(), site_reach: reach.optional(), device_mesh: z.boolean().optional() }, { destructive: true, validate: input => { if (!["hub_and_spoke", "site_reach", "device_mesh"].some(field => input[field] !== undefined))
361
+ throw new Error("Set at least one VPN site option."); } }),
362
+ tool("set_network_vpn_site_alert", "PUT", `${peer}/site-alert`, "Switch the office-down e-mail of one connected location: enabled=true sends one e-mail to the account when the location's router has not reached the VPN for 5 minutes and one more when it is back. The first observation after switching it on only records the state (state null until then). Devices are refused (404 vpn_peer_not_found). 503 vpn_site_routing_unavailable while site-to-site is not enabled.", { enabled: z.boolean() }, {}),
363
+ tool("set_network_site_route_mode", "PUT", `${A}/networks/{network_id}/site-route-mode`, "Choose how the servers of a network reach its VPN's connected locations: dhcp (default) = DHCP classless static routes, picked up at the next lease renewal (about 30 minutes) or a DHCP renew/restart, never by servers with a fixed address; router = an internal network router (no NAT, no external address, no price) carries the routes at once. Has no effect on a network with a NAT gateway. Every VPN of the network moves to a new route line at once. 409 while a server of the network is suspended for abuse.", { route_mode: z.enum(["dhcp", "router"]) }, { destructive: true }),
157
364
  tool("get_network_vpn_egress_options", "GET", `${vpn}/egress-options`, "Read supported VPN Internet exits and qualification. Ordinary VMs and remote connectors are not implied to be supported."),
158
365
  tool("set_network_vpn_peer_egress", "PUT", `${peer}/egress-policy`, "Set a device's private-only, inherited NAT, or advertised chosen exit policy. Read egress options first; Internet routing requires explicit client-route opt-in and applied qualification.", { expected_generation: generation, policy: egressPolicy }, { idempotent: true, destructive: true }),
159
- tool("list_network_products", "GET", `${N}/products`, "Read current paid products, live tier names, prices and purchase capability; public proxy load balancer 3/5-node offerings must come from this catalog. Pass an owned network_id to include that target's purchase eligibility and refusal reasons.", { network_id: uuid.optional() }, { query: ["network_id"] }),
160
- tool("list_network_product_periods", "GET", `${N}/product-periods`, "Read owned product periods, renewal availability and lifecycle facts."),
161
- tool("quote_network_product", "POST", `${networks}/product-quotes`, "Create the network product's exact paid quote for COMMON CHECKOUT. No charge or stock preparation occurs here. prepare_after_payment=true is always sent. Catalog product load_balancer maps to request kind private_lb for tier nlb or proxy_lb for tiers alb_proxy_3/alb_proxy_5; vpn_gateway maps directly. Only use currently supported catalog tiers. Create with order_network_product after disclosure and approval.", { kind: z.enum(["private_lb", "proxy_lb", "vpn_gateway"]), tier: z.string().regex(/^[a-z][a-z0-9_]*$/) }, { quote: true, fixedBody: { prepare_after_payment: true } }),
162
- tool("quote_account_vpn", "POST", `${N}/vpn/product-quotes`, "Create an account VPN quote for common checkout when vpn_account_order is advertised. Pass its owned attachment network when required; no provisioning or charge occurs before payment. Uses prepare_after_payment=true.", { tier: z.string().regex(/^[a-z][a-z0-9_]*$/), network_id: uuid.optional() }, { quote: true, fixedBody: { prepare_after_payment: true } }),
163
- tool("order_network_product", "POST", `${N}/product-orders`, "Confirm a common-checkout purchase from an approved exact quote. Use its quote_id, quote_token and ORIGINAL idempotency key. payment.payment is the account-balance method ID from get_network_payment_methods; do not guess it. Optional target attaches an address to a network after settlement. After any uncertain result call find_network_product_order with the quote ID, then poll get_network_product_order; never buy a second copy.", { quote_id: uuid, payment: z.object({ payment: z.number().int().positive(), paymentid: z.number().int().positive().optional() }).strict(), target: z.object({ network_id: uuid, expected_generation: generation }).strict().optional() }, { paid: true }),
366
+ tool("list_network_products", "GET", `${N}/products`, "Read current paid products, prices, order periods, distinct renew_periods and purchase capability. VPN is one canonical product; deprecated VPN alias rows are omitted from this sellable catalogue while owned/legacy quote evidence remains unchanged. Load-balancer tier names and prices remain backend-defined; public proxy load balancer 3/5-node offerings must come from this catalog. Pass an owned network_id to include that target's purchase eligibility and refusal reasons.", { network_id: uuid.optional() }, { query: ["network_id"] }),
367
+ tool("list_network_product_periods", "GET", `${N}/product-periods`, "Read owned product periods, renewal availability and lifecycle facts. Without resource_id the answer holds at most the first 100 products; incomplete=true means products were left out or a period could not be read, so ask again with resource_id for a product on screen. resource_id (the VPN or load balancer id) returns only that product.", { resource_id: uuid.optional() }, { query: ["resource_id"] }),
368
+ tool("quote_network_product", "POST", `${networks}/product-quotes`, "Create the network product's exact paid quote for COMMON CHECKOUT. No charge or stock preparation occurs here. prepare_after_payment=true is always sent. Catalog product load_balancer maps to request kind private_lb for tier nlb or proxy_lb for tiers alb_proxy_3/alb_proxy_5; vpn_gateway maps directly. Only use currently supported catalog codes; VPN has one product, and its tier code is not a feature/plan choice. Prefer quote_account_vpn with omitted tier for a new VPN quote; preserve original codes for legacy recovery. Create with order_network_product after disclosure and approval.", { kind: z.enum(["private_lb", "proxy_lb", "vpn_gateway"]), tier: z.string().regex(/^[a-z][a-z0-9_]*$/), period_months: periodMonths.optional() }, { quote: true, fixedBody: { prepare_after_payment: true } }),
369
+ tool("quote_account_vpn", "POST", `${N}/vpn/product-quotes`, "Quote the one canonical VPN product based on the current Firecracker plan for common checkout when vpn_account_order is advertised. Omit tier for a new request; standard/full_exit are backend compatibility/proof codes, not sellable VPN plan choices. Additional traffic is purchased separately with quote_network_vpn_traffic_pack. Internet exit is a gateway setting. Pass an owned attachment network when required; no provisioning or charge occurs before payment. Uses prepare_after_payment=true.", { tier: z.string().regex(/^[a-z][a-z0-9_]*$/).optional().describe("Compatibility code only: omit for the one current VPN product. Preserve an existing legacy request code only when recovering that unchanged quote intent; this is not a VPN plan choice."), network_id: uuid.optional(), period_months: periodMonths.optional() }, { quote: true, fixedBody: { prepare_after_payment: true } }),
370
+ tool("order_network_product", "POST", `${N}/product-orders`, "Confirm a common-checkout purchase from an approved exact quote. Use its quote_id, quote_token and ORIGINAL idempotency key. payment.payment is the account-balance method ID from get_network_payment_methods; do not guess it. Optional target attaches an address to a network after settlement. After any uncertain result call find_network_product_order with the quote ID, then poll get_network_product_order; never buy a second copy.", { quote_id: uuid, payment: z.object({ payment: unboundedInteger().positive(), paymentid: unboundedInteger().positive().optional() }).strict(), target: z.object({ network_id: uuid, expected_generation: generation }).strict().optional() }, { paid: true }),
164
371
  tool("get_network_product_order", "GET", `${N}/product-orders/{order_id}`, "Read an owned common-checkout order's payment and fulfilment state. paid_pending with billing_state=captured and reason_code=network_product_ip_inventory_wait means this paid order awaits public IP inventory and resumes automatically when available; keep polling this order without another purchase, payment or manual retry. paid_pending is not ready. manual_review or network_product_stock_reconciliation_required needs operator review, not an automatic inventory retry. fulfilled may include a separate attachment status."),
165
372
  tool("find_network_product_order", "GET", `${N}/product-orders`, "Recover an uncertain checkout by the ORIGINAL quote_id; order=null means no order was found, not proof to use a new payment key.", { quote_id: uuid }, { query: ["quote_id"] }),
166
- tool("quote_network_product_renewal", "POST", `${networks}/product-periods/{resource_id}/quote`, "Quote one next paid month for a network product; no charge. Preserve the returned quote and this idempotency key for renewal.", {}, { quote: true }),
373
+ tool("quote_network_product_renewal", "POST", `${networks}/product-periods/{resource_id}/quote`, "Quote the selected paid term (one month when omitted) for a network product; no charge. Preserve the returned quote and this idempotency key for renewal.", { period_months: periodMonths.optional() }, { quote: true }),
167
374
  tool("renew_network_product", "POST", `${networks}/product-periods/{resource_id}/renew`, "Pay the exact previously disclosed renewal from account balance. The quote binds resource, paid term and price; use the same key and token.", { quote_id: uuid }, { paid: true }),
168
375
  tool("disable_network_product_auto_renew", "POST", `${networks}/product-periods/{resource_id}/auto-renew`, "Disable automatic renewal. The paid product continues to its paid-until time; API keys cannot enable automatic renewal.", {}, { fixedBody: { state: false } }),
169
- tool("quote_vpn_period_renewal", "POST", `${vpn}/period/quote`, "Quote the VPN's next month on its actual paid scope; no charge. Availability follows the owned gateway's current capability.", {}, { quote: true }),
170
- tool("renew_vpn_period", "POST", `${vpn}/period/renew`, "Pay the VPN's approved next-month renewal from account balance using the exact quote and original key.", { quote_id: uuid }, { paid: true }),
376
+ tool("quote_vpn_period_renewal", "POST", `${vpn}/period/quote`, "Quote the VPN's selected term (one month when omitted) on its actual paid scope; no charge. Availability follows the owned gateway's current capability.", { period_months: periodMonths.optional() }, { quote: true }),
377
+ tool("quote_network_vpn_traffic_pack", "POST", `${vpn}/traffic-packs/quote`, "Quote separately purchased additional traffic for this owned VPN using an offered pack code from get_network_vpn_gateway traffic; this does not buy or change a VPN plan. No charge occurs. Packs apply to the current UTC month; read the live quote terms and amount. Confirm only through order_network_product with the exact returned quote, token and ORIGINAL idempotency key after approval.", { tier: z.string().regex(/^[a-z0-9][a-z0-9_]*$/) }, { quote: true }),
378
+ tool("renew_vpn_period", "POST", `${vpn}/period/renew`, "Pay the VPN's approved quoted renewal term from account balance using the exact quote and original key.", { quote_id: uuid }, { paid: true }),
171
379
  tool("disable_vpn_auto_renew", "POST", `${vpn}/auto-renew`, "Disable the VPN's automatic renewal; the current paid period remains. API keys cannot enable it.", {}, { fixedBody: { state: false } }),
172
- tool("list_network_public_addresses", "GET", `${N}/public-addresses`, "Read owned paid public addresses, account limits, target support and purchase availability."),
380
+ tool("list_network_public_addresses", "GET", `${N}/public-addresses`, "Read owned paid public addresses, account limits, target support and purchase availability. A target is a network (NAT), a server's private port, or - only while server_public_target_enabled is true - the public interface of a server that has its own public IPv4 (target.kind server_public, with server and carrier details)."),
173
381
  tool("get_network_public_address", "GET", address, "Read one owned public address, attachment and paid-period state."),
174
- tool("quote_network_public_address", "POST", `${N}/public-addresses/quote`, "Quote a new paid public address for common checkout; no charge. Optional network_id preflights attachment limits before payment. Always prepare_after_payment=true; confirm through order_network_product with matching target if attaching.", { network_id: uuid.optional() }, { quote: true, fixedBody: { prepare_after_payment: true } }),
175
- tool("attach_public_address_to_network", "POST", `${address}/attach`, "Attach or move an already-paid address to an owned network's NAT. Uses NETWORK generation; moving it removes the prior target's use of this address. No purchase occurs.", { network_id: uuid, expected_generation: generation }, { destructive: true }),
176
- tool("attach_public_address_to_server", "POST", `${address}/attach`, "Attach or move an owned paid public address to an eligible zero-public-IP server's private port. Read server_target_enabled and candidate state; movement affects the previous target.", { private_port_id: uuid }, { destructive: true }),
382
+ tool("quote_network_public_address", "POST", `${N}/public-addresses/quote`, "Quote a new paid public address for common checkout; no charge. Optional network_id preflights attachment limits before payment. Always prepare_after_payment=true; confirm through order_network_product with matching target if attaching.", { network_id: uuid.optional(), period_months: periodMonths.optional() }, { quote: true, fixedBody: { prepare_after_payment: true } }),
383
+ tool("attach_public_address_to_network", "POST", `${address}/attach`, "Attach an already-paid address to an owned network's NAT using the NETWORK generation. Pass move=true only after approving removal from its previous target; omitted move preserves backend refusal of implicit movement. No purchase occurs.", { network_id: uuid, expected_generation: generation, move: z.boolean().optional() }, { destructive: true }),
384
+ tool("attach_public_address_to_server", "POST", `${address}/attach`, "Attach an owned paid public address to an eligible zero-public-IP server's private port. Read server_target_enabled and candidate state. Pass move=true only after approving interruption of the previous target; omitted move keeps the backend refusal of implicit movement.", { private_port_id: uuid, move: z.boolean().optional() }, { destructive: true }),
385
+ tool("list_public_address_server_targets", "GET", `${N}/public-addresses/server-targets`, "Search and page the account's servers as targets for attach_public_address_to_server_interface. Each item has service_id, name, platform, own_ipv4 and the backend's own verdict: eligible, and when false a reason (for example public_address_server_without_own_ipv4, public_address_server_address_limit when a VPS already has 16 additional addresses, public_address_server_type_not_available); do not filter by yourself. The page is empty while the platform does not offer this (get_networking_capabilities public_address_server_public). Pass next_cursor as cursor with an unchanged search to continue; service_id resolves one exact server.", { search: z.string().min(1).max(128).optional(), cursor: z.string().min(1).max(512).optional(), limit: z.number().int().min(1).max(100).optional(), service_id: serviceId.optional() }, { query: ["search", "cursor", "limit", "service_id"] }),
386
+ tool("attach_public_address_to_server_interface", "POST", `${address}/attach`, "Attach an owned paid public address directly to the public interface of a server that has its own public IPv4 (service_id from list_public_address_server_targets with eligible true). The address is added next to the server's own address; that address, its IP rows and reverse DNS are never touched; no network, router or NAT is involved and no purchase occurs. Offered only while get_networking_capabilities reports public_address_server_public true: this tool reads it first and refuses without any change otherwise. Pass move=true only after approving that the address leaves its previous target; a move away from such a server completes only after the server's node confirmed the address withdrawn (pending_target until then). The attach is accepted before it is applied: read get_network_public_address (target.kind server_public; carrier.node_state pending, routed, withdrawn or refused; carrier.guest_state pending, applied, customer_managed, agent_unavailable or unsupported). When guest_state is customer_managed, agent_unavailable or unsupported, nothing was written inside the server and carrier.instruction (one_liner, file_path, file_text) is what the customer runs there. check_network_public_address_reach asks again.", { service_id: serviceId, move: z.boolean().optional() }, { destructive: true, requiresCapability: "public_address_server_public" }),
387
+ tool("check_network_public_address_reach", "POST", `${address}/reach-check`, "Ask again whether an owned public address attached to a server's public interface is reachable from the internet, and ask the server's own step again (for example after the server agent was started). It answers at once with reach (state reachable, misrouted, no_answer, checking or unknown, with reason_code and checked_at) while a platform-side check runs; read get_network_public_address afterwards. no_answer can simply mean the server's firewall blocks ping: test a port of the customer's own service. Never changes the attachment and never gates anything. At most one check a minute per address and 10 per 10 minutes per account (429 with retry_after in seconds); 409 public_address_reach_unavailable when the address is not on such a server.", {}, { destructive: false }),
388
+ tool("set_public_address_outgoing_source", "POST", `${address}/outgoing-source`, "Make an owned additional address the source of a server's new outgoing IPv4 connections (enabled=true), or go back to the server's own IP (enabled=false). Free. Only for an address attached to the public interface of a VPS (target.kind server_public) whose target.carrier.outgoing.offered is true; a server has at most one such address, so switching another one on replaces it. Replies, IPv6, sockets a program binds itself and NAT-ed container traffic do not change; the other side of a new outgoing connection sees the chosen address. The same value again answers outcome unchanged. When the address leaves the server the platform first moves the outgoing source back to the server's own IP. Refusals: 409 public_address_outgoing_not_on_server, public_address_outgoing_node_not_ready, public_address_outgoing_server_type_not_available (Cloud VPS), public_address_change_pending; 423 while the server's two-factor lock is closed. Accepted is not applied: read target.carrier.outgoing.state with get_network_public_address.", { enabled: z.boolean() }, { destructive: true }),
389
+ tool("recheck_public_address_outgoing_source", "POST", `${N}/public-addresses/outgoing-source/recheck`, "Ask a server again to put its outgoing source back on its own IP, for an entry of outgoing_source_warnings (an address already left the server, which still tries to send from it). Free; nothing else changes. outcome unchanged when nothing is owed for that server or a request for it still waits for its answer; 404 workload_not_found for a server that is not this account's.", { service_id: serviceId.describe("The server's service number from the outgoing_source_warnings entry") }, {}),
390
+ tool("get_public_ip_rdns", "GET", rdnsTargets.address, "Read the reverse DNS (PTR) of an owned public address that is not a server's own primary address: an additional address, a floating binding, a VPN's or a public load balancer's address (target). Free. hostname is the requested name (null requests removal), applied_hostname the last name the DNS server confirmed, state unset, pending, applied or failed with reason_code, revision the value to send as expected_revision, applied_at the last confirmed write (not resolver cache expiry).", { target: rdnsTarget }, { targetPaths: rdnsTargets }),
391
+ tool("set_public_ip_rdns", "POST", rdnsTargets.address, "Set the reverse DNS (PTR) hostname of an owned public address (target) at the revision read with get_public_ip_rdns. Free; no forward DNS record is required. The name is trimmed and lowercased; an invalid one is refused with 422 public_address_rdns_hostname_invalid (validation_code), a stale revision with 409 public_address_rdns_revision_conflict (read again). The answer is state pending until the DNS server confirms it (applied). After an uncertain result read again and compare hostname instead of sending it twice. 423 while the server the address is attached to has its two-factor lock closed.", { target: rdnsTarget, hostname: z.string().min(1).max(254), expected_revision: rdnsRevision }, { destructive: true, targetPaths: rdnsTargets }),
392
+ tool("reset_public_ip_rdns", "POST", `${rdnsTargets.address}/reset`, "Remove the custom reverse DNS of an owned public address (target) at the revision read with get_public_ip_rdns; a paid address gets no automatic name back. Free. The answer stays pending until the DNS server confirms the removal; moving or detaching the address does not remove it.", { target: rdnsTarget, expected_revision: rdnsRevision }, { destructive: true, targetPaths: rdnsSuffix("/reset") }),
177
393
  tool("detach_network_public_address", "POST", `${address}/detach`, "Detach an owned public address from its current target; it stays owned for the paid period. This can remove Internet connectivity.", {}, { destructive: true }),
178
- tool("quote_public_address_renewal", "POST", `${address}/renewal-quote`, "Quote the address's next month without charging; use the returned quote and this key for confirmation.", {}, { quote: true }),
394
+ tool("quote_public_address_renewal", "POST", `${address}/renewal-quote`, "Quote the address's selected term (one month when omitted) without charging; use the returned quote and this key for confirmation.", { period_months: periodMonths.optional() }, { quote: true }),
179
395
  tool("renew_network_public_address", "POST", `${address}/renew`, "Pay the approved address renewal from balance with its exact quote ID/token and original key.", { quote_id: uuid }, { paid: true }),
180
396
  tool("disable_public_address_auto_renew", "POST", `${address}/auto-renew`, "Disable address automatic renewal; ownership lasts until paid_until. There is no early paid-address cancellation route. API keys cannot enable automatic renewal.", {}, { fixedBody: { state: false } }),
181
- tool("get_network_public_binding", "GET", binding, "Read one existing owned public binding's generation, state and assigned address. Poll this resource after bind, move, forward or delete; public-binding operation IDs are not accepted by get_networking_operation or get_network_appliance_operation. Existing bindings are distinct from purchasing an account public address."),
397
+ tool("get_network_public_binding", "GET", binding, "Read one existing owned public binding's generation, state and assigned address. Poll this resource after bind, move or delete; public-binding operation IDs are not accepted by get_networking_operation or get_network_appliance_operation. Existing bindings are distinct from purchasing an account public address."),
182
398
  tool("bind_network_public_ip", "POST", `${binding}/bind`, "Bind an already reserved floating public binding to an owned private port when supported. Does not purchase an address.", { expected_generation: policyGeneration, private_port_id: uuid }, { idempotent: true, destructive: true }),
183
399
  tool("move_network_public_ip", "POST", `${binding}/move`, "Move a prepared floating public binding to another owned private port; confirm interruption to the old target.", { expected_generation: generation, private_port_id: uuid }, { idempotent: true, destructive: true }),
184
- tool("configure_network_public_forwarding", "POST", `${binding}/forward`, "Configure an existing reserved forwarding binding's TCP/UDP listeners. Forwarding uses one backend per listener; proxy load balancers use their own appliance authority and are refused here.", { expected_generation: policyGeneration, listeners: z.array(z.object({ protocol: z.enum(["tcp", "udp"]), port, backends: z.array(z.object({ private_port_id: uuid, port }).strict()).min(1).max(64) }).strict()).min(1).max(64) }, { idempotent: true, destructive: true }),
185
400
  tool("delete_network_public_binding", "DELETE", binding, "Request deletion of an existing binding using its generation. Account address ownership/paid renewal is managed separately; do not claim a refund.", { expected_generation: policyGeneration }, { idempotent: true, destructive: true }),
186
401
  ];
187
402
  export function networkingRequest(definition, args) {
188
403
  const input = definition.schema.parse(args);
189
- let path = definition.path;
404
+ definition.options.validate?.(input);
405
+ let path = definition.options.targetPaths ? definition.options.targetPaths[String(input.target)] : definition.path;
406
+ if (path === undefined)
407
+ throw new Error(`${definition.name}: unknown target`);
190
408
  for (const field of definition.pathFields)
191
409
  path = path.replace(`{${field}}`, encodeURIComponent(String(input[field])));
192
410
  const query = new URLSearchParams();
193
411
  for (const field of definition.options.query ?? []) {
194
412
  if (input[field] !== undefined)
195
- query.set(field, String(input[field]));
413
+ query.set(field, typeof input[field] === "object" ? JSON.stringify(input[field]) : String(input[field]));
196
414
  }
415
+ for (const [field, value] of Object.entries(definition.options.fixedQuery ?? {}))
416
+ query.set(field, value);
197
417
  if (query.size)
198
418
  path += `?${query}`;
199
419
  const headers = {};
@@ -201,8 +421,9 @@ export function networkingRequest(definition, args) {
201
421
  headers["Idempotency-Key"] = String(input.idempotencyKey);
202
422
  if (input.quoteToken !== undefined)
203
423
  headers["X-Quote-Token"] = String(input.quoteToken);
204
- const excluded = new Set([...definition.pathFields, ...(definition.options.query ?? []), "idempotencyKey", "quoteToken", "acknowledge_charge", "acknowledge_one_time_pickup"]);
205
- const body = definition.method === "GET" ? undefined : {
424
+ const excluded = new Set([...definition.pathFields, ...(definition.options.query ?? []), "idempotencyKey", "quoteToken", "acknowledge_charge", "acknowledge_one_time_pickup",
425
+ ...(definition.options.targetPaths ? ["target"] : [])]);
426
+ const body = definition.method === "GET" || definition.options.bodyless ? undefined : {
206
427
  ...Object.fromEntries(Object.entries(input).filter(([field, value]) => !excluded.has(field) && value !== undefined)),
207
428
  ...(definition.options.fixedBody ?? {}),
208
429
  };
@@ -222,14 +443,163 @@ export function networkingProgressHint(data) {
222
443
  if (!operation || typeof operation.in_progress !== "boolean")
223
444
  return null;
224
445
  const reason = typeof operation.reason_code === "string" && operation.reason_code !== "" ? operation.reason_code : "none";
446
+ const vpnTelemetry = typeof operation.kind === "string" && operation.kind.startsWith("vpn_") && typeof operation.started_at === "string"
447
+ ? " VPN telemetry: started_at is immutable acceptance time; last_progress_at is durable action progress; last_observed_at, when present, is a changed agent heartbeat at this action's generation; progress_stale is age of action progress, not tunnel health. stage labels progress; outcome and finished_at mark a terminal result. Receipt, observation, FRR selection and terminal success do not prove connectivity."
448
+ : "";
225
449
  if (operation.in_progress) {
226
- const wait = Number.isInteger(operation.next_check_seconds) ? `${operation.next_check_seconds} s` : "a few seconds";
227
- return `Operation ${operation.id ?? ""} in progress (state=${operation.state}, reason_code=${reason}, reason_kind=${operation.reason_kind ?? "waiting"}): this is progress, not an error. Poll the follow-up read again in ${wait}; do not repeat the change.`;
450
+ const wait = Number.isInteger(operation.next_check_seconds) && operation.next_check_seconds > 0
451
+ ? `in ${operation.next_check_seconds} s` : "after an interval";
452
+ return `Operation ${operation.id ?? ""} in progress (state=${operation.state}, reason_code=${reason}, reason_kind=${operation.reason_kind ?? "waiting"}): this is progress, not an error. Poll the follow-up read again ${wait}; do not repeat the change.${vpnTelemetry}`;
228
453
  }
229
454
  if (operation.reason_kind === "failure") {
230
- return `Operation ${operation.id ?? ""} failed (state=${operation.state}, reason_code=${reason}). Do not retry automatically; report the reason_code.`;
455
+ return `Operation ${operation.id ?? ""} failed (state=${operation.state}, reason_code=${reason}). Do not retry automatically; report the reason_code.${vpnTelemetry}`;
456
+ }
457
+ return `Operation ${operation.id ?? ""} finished (state=${operation.state}, reason_code=${reason}).${vpnTelemetry}`;
458
+ }
459
+ // VPN responses may carry one-time setup material. Keep ciphertext in its
460
+ // reviewed envelope, but never hand plaintext keys or arbitrary error text to
461
+ // an AI client, including when the backend adds a nested field in the future.
462
+ export function safeVpnPayload(data, status) {
463
+ if (status >= 400) {
464
+ const value = data && typeof data === "object" && !Array.isArray(data) ? data : {};
465
+ const safe = { http_status: status };
466
+ for (const field of ["reason_code", "code", "error", "field"]) {
467
+ if (typeof value[field] === "string" && /^[a-z][a-z0-9_.-]{0,127}$/.test(value[field]))
468
+ safe[field] = value[field];
469
+ }
470
+ if (status === 423 && value.code === "serviceTwoFactorLocked") {
471
+ safe.code = "serviceTwoFactorLocked";
472
+ safe.serviceTwoFactorLocked = true;
473
+ if (["locked", "apiKeyNotAllowed", "assistantNotAllowed", "impersonationNotAllowed", "adminNeedsCustomerUnlock", "transferNeedsLockOff"].includes(String(value.reason)))
474
+ safe.reason = value.reason;
475
+ if (typeof value.unlockAvailable === "boolean")
476
+ safe.unlockAvailable = value.unlockAvailable;
477
+ if (serviceId.safeParse(value.orderNo).success)
478
+ safe.orderNo = value.orderNo;
479
+ }
480
+ if (Number.isInteger(value.retry_after) && Number(value.retry_after) > 0)
481
+ safe.retry_after = value.retry_after;
482
+ if (value.non_api_response === true)
483
+ safe.non_api_response = true;
484
+ if (value.outcome_unknown === true) {
485
+ safe.outcome_unknown = true;
486
+ safe.message = "The request outcome is unknown. Inspect state before retrying with the same key and unchanged input.";
487
+ }
488
+ return safe;
231
489
  }
232
- return `Operation ${operation.id ?? ""} finished (state=${operation.state}, reason_code=${reason}).`;
490
+ const project = (value, depth) => {
491
+ if (depth > 24)
492
+ return "[omitted]";
493
+ if (Array.isArray(value))
494
+ return value.map(item => project(item, depth + 1));
495
+ if (value && typeof value === "object") {
496
+ const record = value;
497
+ const out = {};
498
+ for (const [field, item] of Object.entries(record)) {
499
+ const normalized = field.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
500
+ if (field === "sealed") {
501
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
502
+ out.sealed = "[invalid sealed material]";
503
+ continue;
504
+ }
505
+ const sealed = item;
506
+ const names = Object.keys(sealed).sort();
507
+ if (JSON.stringify(names) !== JSON.stringify(["ciphertext", "ephemeral_public_key", "nonce"])
508
+ || names.some(name => typeof sealed[name] !== "string")) {
509
+ out.sealed = "[invalid sealed material]";
510
+ continue;
511
+ }
512
+ out.sealed = { ephemeral_public_key: sealed.ephemeral_public_key, nonce: sealed.nonce, ciphertext: sealed.ciphertext };
513
+ }
514
+ else if (field === "sealed_secret") {
515
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
516
+ out.sealed_secret = "[invalid sealed material]";
517
+ continue;
518
+ }
519
+ const sealed = item;
520
+ const names = Object.keys(sealed).sort();
521
+ const valid = (value, bytes) => {
522
+ if (typeof value !== "string" || value.length !== 4 * Math.ceil(bytes / 3))
523
+ return false;
524
+ const decoded = Buffer.from(value, "base64");
525
+ return decoded.length === bytes && decoded.toString("base64") === value;
526
+ };
527
+ // Backend seals {"secret":"<64 hex chars>"}: 77 JSON bytes plus the 16-byte NaCl box MAC.
528
+ const ciphertextBytes = 93;
529
+ if (JSON.stringify(names) !== JSON.stringify(["ciphertext", "ephemeral_public_key", "nonce"])
530
+ || !valid(sealed.ephemeral_public_key, 32) || !valid(sealed.nonce, 24)
531
+ || !valid(sealed.ciphertext, ciphertextBytes)) {
532
+ out.sealed_secret = "[invalid sealed material]";
533
+ continue;
534
+ }
535
+ out.sealed_secret = { ephemeral_public_key: sealed.ephemeral_public_key, nonce: sealed.nonce, ciphertext: sealed.ciphertext };
536
+ }
537
+ else if (normalized !== "psk_lost_hint" && normalized !== "psk_placeholder" && normalized !== "quote_token"
538
+ && (/^(?:psk|pre_shared_key|private_key|secret|password|credential|token)(?:_|$)/.test(normalized)
539
+ || /_(?:psk|pre_shared_key|private_key|secret|password|credential|token)$/.test(normalized)
540
+ || /^(?:config|configuration|ciphertext|raw_material)$/.test(normalized))) {
541
+ out[field] = "[redacted]";
542
+ }
543
+ else
544
+ out[field] = project(item, depth + 1);
545
+ }
546
+ return out;
547
+ }
548
+ if (typeof value === "string")
549
+ return value
550
+ .replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi, "[redacted]")
551
+ .replace(/\b(?:psk|pre[-_ ]?shared[-_ ]?key|private[-_ ]?key|password|secret)\s*[:=]\s*([^\s,;]+)/gi, (match, material) => material === "{{PSK}}" ? match : "[redacted]");
552
+ return value;
553
+ };
554
+ return project(data, 0);
555
+ }
556
+ /**
557
+ * The firewall answer carries client_address, the address the API saw for this request. Over a hosted connection
558
+ * that is the connector's gateway, never the customer's computer, so it is returned as null there; the shape stays
559
+ * the backend's (client_address is nullable).
560
+ */
561
+ export function publicFirewallPayload(data, hosted) {
562
+ if (!hosted || !data || typeof data !== "object" || Array.isArray(data) || !("client_address" in data))
563
+ return data;
564
+ return { ...data, client_address: null };
565
+ }
566
+ /** Keep only the backend's public destination host, even if a future response adds a path. */
567
+ export function safeVpnWebhookPayload(data, status) {
568
+ const safe = safeVpnPayload(data, status);
569
+ if (status >= 400 || !safe || typeof safe !== "object" || Array.isArray(safe))
570
+ return safe;
571
+ const record = safe;
572
+ const pick = (source, fields) => Object.fromEntries(fields
573
+ .filter(field => source[field] !== undefined).map(field => [field, source[field]]));
574
+ const display = (subscription) => {
575
+ if (!subscription || typeof subscription !== "object" || Array.isArray(subscription))
576
+ return {};
577
+ const item = subscription;
578
+ const shown = pick(item, ["id", "enabled", "created_at"]);
579
+ if (item.last_delivery === null)
580
+ shown.last_delivery = null;
581
+ else if (item.last_delivery && typeof item.last_delivery === "object" && !Array.isArray(item.last_delivery)) {
582
+ shown.last_delivery = pick(item.last_delivery, ["event_id", "transition", "occurred_at", "status", "http_status", "attempts", "next_attempt_at", "delivered_at"]);
583
+ }
584
+ try {
585
+ const url = new URL(typeof item.url === "string" ? item.url : "");
586
+ const host = url.hostname;
587
+ shown.url = url.protocol === "https:" && /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{1,62}$/.test(host)
588
+ ? `https://${host}` : "https://invalid.invalid";
589
+ }
590
+ catch {
591
+ shown.url = "https://invalid.invalid";
592
+ }
593
+ return shown;
594
+ };
595
+ if (Array.isArray(record.subscriptions))
596
+ return { subscriptions: record.subscriptions.map(display) };
597
+ if (record.subscription !== undefined)
598
+ return { subscription: display(record.subscription),
599
+ ...(record.sealed_secret !== undefined ? { sealed_secret: record.sealed_secret } : {}) };
600
+ if (typeof record.removed === "boolean")
601
+ return { removed: record.removed };
602
+ return {};
233
603
  }
234
604
  /** Read-only recovery targets. IDs are copied from validated inputs or owned API results, never guessed. */
235
605
  export function networkingFollowUpReads(definition, args, data) {
@@ -254,6 +624,18 @@ export function networkingFollowUpReads(definition, args, data) {
254
624
  }
255
625
  if (definition.method === "GET")
256
626
  return reads;
627
+ if (definition.options.targetPaths) {
628
+ // Reverse DNS: read the same object again; its revision and requested hostname settle an uncertain result.
629
+ if (uuid.safeParse(args.resource_id).success && typeof args.target === "string" && definition.options.targetPaths[args.target]) {
630
+ reads.push({ tool: "get_public_ip_rdns", arguments: { target: args.target, resource_id: String(args.resource_id) } });
631
+ }
632
+ return reads;
633
+ }
634
+ if (definition.path.includes("/public-firewall")) {
635
+ if (serviceId.safeParse(args.service_id).success)
636
+ reads.push({ tool: "get_service_public_firewall", arguments: { service_id: String(args.service_id) } });
637
+ return reads;
638
+ }
257
639
  if (definition.path.includes("/projects/")) {
258
640
  const project = args.project_id ?? value.policy?.project_id;
259
641
  if (args.network_id)
@@ -269,6 +651,9 @@ export function networkingFollowUpReads(definition, args, data) {
269
651
  }
270
652
  else if (definition.path.includes("/appliances/")) {
271
653
  add("get_network_appliance_operation", { operation_id: value.operation?.id });
654
+ if (definition.name === "set_network_vpn_site_options" || definition.name === "set_network_vpn_site_alert") {
655
+ add("get_network_vpn_gateway", { gateway_id: args.gateway_id });
656
+ }
272
657
  if (definition.name === "set_vpn_server_access") {
273
658
  // The setting's operation can be succeeded while its separate network policy is pending.
274
659
  const network = value.vpn_gateway?.network_id;
@@ -284,6 +669,10 @@ export function networkingFollowUpReads(definition, args, data) {
284
669
  if (args.network_id)
285
670
  add("get_network_routed_egress", { network_id: args.network_id });
286
671
  }
672
+ else if (definition.name === "set_network_port_forwards") {
673
+ // The accepted change is followed on the rules resource itself (router generation), not on a generic operation read.
674
+ add("get_network_port_forwards", { network_id: args.network_id });
675
+ }
287
676
  else if (definition.path.includes("/networks")) {
288
677
  add("get_networking_operation", { operation_id: value.operation?.id ?? value.operation_id });
289
678
  if (definition.name === "set_network_routed_egress") {
@@ -311,11 +700,32 @@ export function registerNetworkingTools(server) {
311
700
  },
312
701
  }, async (args) => {
313
702
  const request = networkingRequest(definition, args);
703
+ if (definition.options.requiresCapability) {
704
+ // The capability is read from the account before anything is changed; an unreadable or absent flag refuses.
705
+ const capabilities = await apiRequest("GET", `${N}/capabilities`, undefined, {}, { readOnly: true });
706
+ const data = capabilities.data && typeof capabilities.data === "object" && !Array.isArray(capabilities.data)
707
+ ? capabilities.data : {};
708
+ if (capabilities.status >= 400) {
709
+ return { content: [{ type: "text", text: formatJson({ refused: true, changed: false, http_status: capabilities.status,
710
+ message: `${definition.name} was not sent: get_networking_capabilities could not be read, so ${definition.options.requiresCapability} is unknown. Nothing was changed.` }) }], isError: true };
711
+ }
712
+ if (data[definition.options.requiresCapability] !== true) {
713
+ return { content: [{ type: "text", text: formatJson({ refused: true, changed: false, reason_code: `${definition.options.requiresCapability}_not_offered`,
714
+ message: `${definition.name} is not available: get_networking_capabilities does not report ${definition.options.requiresCapability} as true for this account now. Nothing was changed.` }) }], isError: true };
715
+ }
716
+ }
314
717
  const result = await apiRequest(request.method, request.path, request.body, request.headers, { readOnly: request.readOnly });
315
- const reads = result.status < 400 ? networkingFollowUpReads(definition, args, result.data) : [];
316
- const progress = result.status < 400 ? networkingProgressHint(result.data) : null;
718
+ const responseData = definition.name === "list_network_products" && result.status < 400 && result.data && typeof result.data === "object" && !Array.isArray(result.data)
719
+ && Array.isArray(result.data.products)
720
+ ? { ...result.data, products: result.data.products.filter(row => !(row && typeof row === "object" && row.product === "vpn_gateway" && row.deprecated_alias === true)) }
721
+ : result.data;
722
+ const safeData = definition.path.includes("/health-webhooks") ? safeVpnWebhookPayload(responseData, result.status)
723
+ : definition.path.includes("/vpn-gateways") && !definition.options.targetPaths ? safeVpnPayload(responseData, result.status)
724
+ : definition.path.includes("/public-firewall") ? publicFirewallPayload(responseData, authorizationContext() !== undefined) : responseData;
725
+ const reads = result.status < 400 ? networkingFollowUpReads(definition, args, safeData) : [];
726
+ const progress = result.status < 400 ? networkingProgressHint(safeData) : null;
317
727
  return { content: [
318
- { type: "text", text: formatJson(result.data) },
728
+ { type: "text", text: formatJson(safeData) },
319
729
  ...(progress ? [{ type: "text", text: progress }] : []),
320
730
  ...(reads.length ? [{ type: "text", text: `Follow-up reads (do not repeat the change): ${JSON.stringify(reads)}` }] : []),
321
731
  ], ...(result.status >= 400 ? { isError: true } : {}) };