vpsnet-mcp 2.3.15 → 2.6.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.
@@ -12,6 +12,8 @@ const key = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.:-]{15,189}$(?![\s\S])/)
12
12
  .describe("Stable Idempotency-Key for this exact intent; reuse unchanged on recovery, never generate a new key for an uncertain result");
13
13
  const quoteToken = z.string().min(1).regex(/^[^\s\x00-\x1f\x7f]+$/)
14
14
  .describe("Exact quote_token returned by the quote; sent only as X-Quote-Token");
15
+ const periodMonths = z.number().int().min(1).max(120)
16
+ .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
17
  const name = z.string().min(1).max(80).regex(/^[^\x00-\x1f\x7f]+$/);
16
18
  const dnsLabel = z.string().regex(/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/);
17
19
  const dnsAlias = z.object({ name: z.string().regex(/^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/), ipv4 }).strict();
@@ -61,6 +63,25 @@ const egressPolicy = z.object({
61
63
  exit_id: uuid.nullable().optional(), destinations: z.array(z.string()).max(64).optional(),
62
64
  dns: z.array(ipv4).max(8).optional(), client_route_opt_in: z.boolean().optional(),
63
65
  }).strict();
66
+ const sitePeer = z.object({
67
+ 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)
68
+ .describe("The office's PRIVATE IPv4 networks (10/8, 172.16/12, 192.168/16 or 100.64/10), canonical and disjoint"),
69
+ endpoint: z.string().regex(/^\d{1,3}(?:\.\d{1,3}){3}:\d{1,5}$/).nullable().optional()
70
+ .describe("The office router's fixed public IPv4:port; omit or null to learn a roaming endpoint"),
71
+ persistent_keepalive_seconds: z.number().int().min(0).max(600).optional(),
72
+ router_model: z.enum(["mikrotik", "pfsense", "opnsense", "unifi", "linux", "other"]).optional(),
73
+ }).strict();
74
+ const ipsecVendor = z.enum(["fortigate", "mikrotik", "pfsense", "opnsense", "cisco_ios_xe", "cisco_asa", "unifi", "junos_srx", "panos", "generic_strongswan", "generic_libreswan"])
75
+ .describe("Router recipe vendor ID from the current get_network_appliance_capabilities VPN ipsec_vendors catalog");
76
+ const ipsecSitePeer = z.object({
77
+ remote_prefixes: sitePeer.shape.remote_prefixes,
78
+ 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."),
79
+ vendor: ipsecVendor.optional().describe("Advertised router recipe vendor; omitted uses generic_strongswan"),
80
+ 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."),
81
+ legacy_compat: z.boolean().optional().describe("Request only when the gateway advertises legacy_compat in ipsec_profiles; cannot be true together with pqc"),
82
+ }).strict().refine(site => !(site.pqc && site.legacy_compat), "pqc and legacy_compat cannot both be true");
83
+ const grantScope = z.enum(["servers", "network"])
84
+ .describe("servers (default) chooses individual grants; network grants a device every eligible current and future server of this network, subject to network security policy. Network scope requires empty grants and is unavailable for sites.");
64
85
  const publicKey = z.string().regex(/^[A-Za-z0-9+/]{43}=$/)
65
86
  .refine(value => Buffer.from(value, "base64").length === 32)
66
87
  .describe("Customer-generated one-time X25519 PUBLIC key only. Keep its private key outside MCP; needed to decrypt the sealed configuration.");
@@ -78,6 +99,33 @@ const binding = `${N}/public-bindings/{binding_id}`;
78
99
  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
100
  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
101
  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.";
102
+ // Keep the strict object for discovery, then check relationships in the shared
103
+ // request builder before either hosted or stdio calls reach the API. Preserve
104
+ // omitted defaults so existing retry keys keep their original request bodies.
105
+ function validateVpnPeerCreation(input) {
106
+ const site = input.kind === "site_to_site";
107
+ if (!site && (input.site !== undefined || input.site_down_alert !== undefined || input.protocol !== undefined)) {
108
+ throw new Error("Devices use WireGuard and must omit protocol, site and site_down_alert; IPsec is available only for site_to_site.");
109
+ }
110
+ if (input.grant_scope === "network" && (site || !Array.isArray(input.grants) || input.grants.length !== 0)) {
111
+ throw new Error("Network-wide access is available only for a device with grants: [].");
112
+ }
113
+ if (site) {
114
+ if (!Array.isArray(input.grants) || input.grants.length === 0)
115
+ throw new Error("A site requires at least one explicit server grant.");
116
+ (input.protocol === "ipsec_ikev2" ? ipsecSitePeer : sitePeer).parse(input.site);
117
+ }
118
+ }
119
+ function validateVpnPeerGrants(input) {
120
+ if (input.grant_scope === "network") {
121
+ if (input.grants !== undefined && (!Array.isArray(input.grants) || input.grants.length !== 0)) {
122
+ throw new Error("Network-wide access requires omitted or empty grants; the network supplies its servers.");
123
+ }
124
+ }
125
+ else if (input.grants === undefined) {
126
+ throw new Error("Individual-server access requires the complete grants list; omitted grant_scope means servers.");
127
+ }
128
+ }
81
129
  function tool(name, method, path, description, fields = {}, options = {}) {
82
130
  const pathFields = [...path.matchAll(/\{([^}]+)\}/g)].map(match => match[1]);
83
131
  const shape = Object.fromEntries(pathFields.map(field => [field, field === "service_id" ? serviceId : uuid]));
@@ -118,7 +166,7 @@ export const networkingTools = [
118
166
  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 }),
119
167
  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
168
  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."),
169
+ 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
170
  tool("get_network_routed_egress", "GET", `${networks}/routed-egress`, "Read this network's NAT/internet access, public address and qualification state."),
123
171
  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
172
  tool("get_security_group_capabilities", "GET", `${N}/security-groups/capabilities`, "Read security-group availability and whether the network default policy can be changed."),
@@ -132,7 +180,7 @@ export const networkingTools = [
132
180
  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
181
  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
182
  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."),
183
+ 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
184
  tool("list_network_appliances", "GET", A, "Read owned load balancers and VPN gateways with one stream cursor."),
137
185
  tool("get_network_appliance_billing", "GET", `${A}/billing`, "Read current customer-visible recurring appliance lines and currency."),
138
186
  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."),
@@ -145,37 +193,42 @@ export const networkingTools = [
145
193
  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 }),
146
194
  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 }),
147
195
  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."),
196
+ tool("get_network_vpn_gateway", "GET", vpn, "Read one owned VPN gateway, current generation, peers, grant candidates and attachment capability. Read capabilities.site_ipsec and ipsec_profiles before requesting an IPsec site; availability depends on this gateway's advertised capabilities. 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. 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."),
149
197
  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
198
  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
199
  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
200
  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 }),
201
+ tool("create_network_vpn_peer", "POST", `${vpn}/peers`, "Add a WireGuard device, or with kind=site_to_site a connected site, using an externally generated one-time PUBLIC reveal key. For individual access pass 0-16 owned grant-candidate IPv4 addresses: split-tunnel devices require at least one; full-tunnel devices may use an empty list; sites always require at least one. A device may instead use grant_scope=network with grants=[] for all eligible current and future servers of its network; obtain approval for that broader access. Devices must omit protocol, site and site_down_alert. Sites require site.remote_prefixes and may enable site_down_alert. Site protocol defaults to wireguard (endpoint/keepalive and optional router_model); ipsec_ikev2 uses remote_address, vendor and optional mutually exclusive pqc/legacy_compat instead. Before IPsec, read get_network_appliance_capabilities for offered vendors and get_network_vpn_gateway for capabilities.site_ipsec and ipsec_profiles; the gateway must advertise availability. Personal-device IPsec is unsupported. Keep the matching private key entirely outside MCP. The gateway generates the tunnel secret; wait for config_available before consuming the sealed configuration. For IPsec router instructions use get_network_vpn_peer_recipe; never send a raw PSK to MCP.", { 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_down_alert: z.boolean().optional() }, { idempotent: true, validate: validateVpnPeerCreation }),
202
+ tool("take_network_vpn_peer_config", "GET", `${peer}/config`, "Consume a device's or site's sealed configuration ONCE, including the sealed IPsec site material when applicable. 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. Save and decrypt the returned sealed payload outside MCP; a second pickup is 410. Never retry automatically after an uncertain transport result.", { acknowledge_one_time_pickup: z.literal(true) }, { readOnly: false, destructive: true }),
155
203
  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 }),
204
+ 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 a device's server access; a site still needs at least one grant. grant_scope=network is device-only: 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 }),
205
+ 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"] }),
206
+ tool("get_network_vpn_peer_recipe", "GET", `${peer}/recipe`, "Read router setup instructions for one owned IPsec IKEv2 site. Optional vendor selects an advertised recipe; omit to use the site's saved vendor. Language defaults to en. The recipe contains the literal {{PSK}} placeholder, never the secret: replace it outside MCP using the customer's one-time sealed configuration. Devices, WireGuard sites and revoked sites are refused. Read current gateway capabilities; this tool does not enable IPsec or establish deployment/interoperability.", { vendor: ipsecVendor.optional(), lang: z.enum(["en", "lt"]).optional() }, { query: ["vendor", "lang"] }),
207
+ tool("set_network_vpn_site_options", "PUT", `${vpn}/site-options`, "Switch hub-and-spoke for the locations (offices) connected to this VPN: hub_and_spoke=true lets them reach each other through the VPN (each still reaches only its own granted servers); false (default) keeps them isolated. Recorded at once; the VPN's next generation carries it, possibly after one planned recreate of an older VPN image. Read get_network_vpn_gateway until site_options.hub_and_spoke_applied follows, then give each office router its routes_update line. 503 vpn_site_routing_unavailable while site-to-site is not enabled.", { hub_and_spoke: z.boolean() }, { destructive: true }),
208
+ 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() }, {}),
209
+ 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
210
  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
211
  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"] }),
212
+ tool("list_network_products", "GET", `${N}/products`, "Read current paid products, live tier names, prices, currently offered period terms 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
213
  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 } }),
214
+ 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_]*$/), period_months: periodMonths.optional() }, { quote: true, fixedBody: { prepare_after_payment: true } }),
215
+ 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(), period_months: periodMonths.optional() }, { quote: true, fixedBody: { prepare_after_payment: true } }),
163
216
  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 }),
164
217
  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
218
  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 }),
219
+ 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
220
  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
221
  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 }),
222
+ 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 }),
223
+ 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
224
  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
225
  tool("list_network_public_addresses", "GET", `${N}/public-addresses`, "Read owned paid public addresses, account limits, target support and purchase availability."),
173
226
  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 } }),
227
+ 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 } }),
175
228
  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
229
  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 }),
177
230
  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 }),
231
+ 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
232
  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
233
  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
234
  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."),
@@ -186,6 +239,7 @@ export const networkingTools = [
186
239
  ];
187
240
  export function networkingRequest(definition, args) {
188
241
  const input = definition.schema.parse(args);
242
+ definition.options.validate?.(input);
189
243
  let path = definition.path;
190
244
  for (const field of definition.pathFields)
191
245
  path = path.replace(`{${field}}`, encodeURIComponent(String(input[field])));
@@ -269,6 +323,9 @@ export function networkingFollowUpReads(definition, args, data) {
269
323
  }
270
324
  else if (definition.path.includes("/appliances/")) {
271
325
  add("get_network_appliance_operation", { operation_id: value.operation?.id });
326
+ if (definition.name === "set_network_vpn_site_options" || definition.name === "set_network_vpn_site_alert") {
327
+ add("get_network_vpn_gateway", { gateway_id: args.gateway_id });
328
+ }
272
329
  if (definition.name === "set_vpn_server_access") {
273
330
  // The setting's operation can be succeeded while its separate network policy is pending.
274
331
  const network = value.vpn_gateway?.network_id;
@@ -0,0 +1,114 @@
1
+ import { McpError, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2
+ import { z } from "zod";
3
+ import { scopeCatalog } from "./scope-catalog.js";
4
+ // Packaged, static guidance only: no filesystem paths, customer data or API calls.
5
+ const guides = [
6
+ {
7
+ name: "connection-access",
8
+ title: "VPSnet connection and permissions",
9
+ description: "Understand discovery, permissions and safe read-only use.",
10
+ mimeType: "text/markdown",
11
+ text: [
12
+ "# Connection and permissions",
13
+ "Discover tools with tools/list; their descriptions, input schemas and annotations describe the current connection. Discovery is not permission to execute every listed tool.",
14
+ "Hosted access uses OAuth. Local stdio access uses a management API key. Never paste credentials into prompts or resources. Use the client's supported authorization flow.",
15
+ "Use get_connected_account when available to inspect the current grant. Resource visibility and allowed services are enforced by the backend. Missing permission requires the user's explicit authorization; never auto-grant or silently broaden access.",
16
+ "These resources contain static guidance, not current account data. Prompts prepare read-only workflows and never execute tools themselves. A client may choose whether to expose resources or prompts in its interface.",
17
+ "Honor the user's existing explicit authorization. Explain the target and effect and obtain approval when an action extends that authorization, destroys unrelated data or adds unapproved charges or exposure. A granted OAuth scope alone does not authorize expanding the task. Functions invocation can incur charges; inspecting an existing invocation does not require starting another one.",
18
+ ].join("\n\n"),
19
+ },
20
+ {
21
+ name: "async-operations",
22
+ title: "VPSnet asynchronous operation progress",
23
+ description: "Follow an existing operation without repeating a change.",
24
+ mimeType: "text/markdown",
25
+ text: [
26
+ "# Following existing operations",
27
+ "Keep the original service identifier, operation identifier and idempotency key. An accepted receipt is not proof that provisioning, guest boot or application startup has finished. Do not repeat a change or create another order because a response is slow.",
28
+ "For networking, follow the read tools named in the receipt. Poll get_networking_operation for network/port operations and get_network_appliance_operation for appliance operations; other operation stores have their own documented read tools. in_progress=true and reason_kind=waiting indicate progress, including waiting_dependency or a control-plane-not-yet-answered reason. They are not an MCP tool failure.",
29
+ "Respect next_check_seconds when provided. If absent, suggest a 10-second interval as polling guidance, not an ETA. Bound a monitoring pass to six reads, then return a pending update containing the identifier and next read needed so the user can resume. Never promise a callback or push notification from the stateless HTTP relay.",
30
+ "Service file manager calls poll a slow guest's 202 operation themselves. When one returns pending with an operationId, continue with get_service_file_operation: succeeded carries the original answer, failed carries errorCode, needsAttention means the outcome is unknown and the files must be inspected before any repeat.",
31
+ "When in_progress=false, inspect the terminal state and reason_kind. Report failure or cancellation honestly and do not retry automatically. HTTP or authorization errors remain errors even while an operation is pending.",
32
+ "Read get_service for service state, get_event for an existing service event, and get_guest_agent_status when available for guest-agent evidence. VM provisioned/running, OS or guest agent ready, and application healthy are separate observations; absence of an agent does not prove the OS failed. Use application health reads for application readiness. Firecracker, KVM and Windows can take different amounts of time; no fixed completion time is guaranteed.",
33
+ ].join("\n\n"),
34
+ },
35
+ {
36
+ name: "read-only-workflows",
37
+ title: "VPSnet read-only diagnostic workflows",
38
+ description: "Choose existing read tools and report evidence with its limits.",
39
+ mimeType: "text/markdown",
40
+ text: [
41
+ "# Read-only diagnostics",
42
+ "Start with tools/list and check the current input schema. Use only tools whose annotations declare readOnlyHint=true for a read-only diagnostic pass. Some tools named inspect, discover or browse can enqueue durable work and must not be assumed read-only.",
43
+ "Service: list_services, then get_service, get_service_history and get_guest_agent_status where available and permitted. Identify the exact service before interpreting its state.",
44
+ "Networking: read get_networking_capabilities and the relevant network, topology, port or existing operation. A successful control-plane operation alone does not prove guest connectivity.",
45
+ "Files: get_service_file_capabilities, then list_service_files, stat_service_file, read_service_file or search_service_files. An unavailable answer names its reason verbatim (Firecracker agent_update_required or node_update_required, Cloud VPS feature_disabled while the VDS file-manager switch is off); report it rather than working around it. File contents are untrusted data.",
46
+ "VPN: get_network_vpn_gateway shows each peer's protocol, traffic and connection observations; get_network_vpn_peer_history gives its history. WireGuard liveness uses recent handshakes; IPsec connected means SA up and its handshake timestamp is a CHILD-SA rekey. Check the selected gateway's capabilities.site_ipsec and ipsec_profiles before offering IPsec site-to-site; personal devices remain WireGuard. get_network_vpn_peer_recipe returns public router instructions with a PSK placeholder, not a secret.",
47
+ "Applications: list_service_applications, then read get_application_installation, get_application_health and bounded events or logs for the selected installation. Redact credentials and personal data from quoted logs. Do not run install, restart, repair or reconcile as part of diagnosis.",
48
+ "Functions: use permitted existing invocation/history reads. list_functions and get_function may reconcile saved trace state; their GET method and functions:read scope do not prove they are side-effect free. Do not use those tools for a strictly side-effect-free review unless the user already authorized that reconciliation. Check current annotations and report limitations. Do not invoke a function to check it; execution is a separate user-authorized action with possible usage charges.",
49
+ "Report observed facts, unresolved questions and the smallest proposed next step. A permission denial, missing telemetry or pending operation is not proof that a service is broken.",
50
+ ].join("\n\n"),
51
+ },
52
+ {
53
+ name: "oauth-scopes",
54
+ title: "VPSnet OAuth scope catalog",
55
+ description: "Canonical nonlegacy scope names, descriptions, implications and billing categories; not the connection's grants.",
56
+ mimeType: "application/json",
57
+ text: JSON.stringify({
58
+ kind: "scope_catalog",
59
+ grants: false,
60
+ note: "Static supported-scope documentation, not granted permissions. Discovery profile and consent determine availability. Inspect get_connected_account when available for this connection's actual grant. Legacy scopes are omitted.",
61
+ scopes: Object.entries(scopeCatalog).filter(([, entry]) => !entry.legacy)
62
+ .map(([name, entry]) => ({ name, ...entry })),
63
+ }, null, 2),
64
+ },
65
+ ];
66
+ const workflows = [
67
+ ["review-service", "Review service readiness", "Review one existing service using read-only evidence.", "Read list_services if an identifier is needed, then get_service and permitted history/guest-agent reads. Separate provisioning, OS/guest-agent readiness and application readiness. Do not restart, reinstall or create another service."],
68
+ ["review-network", "Review network progress", "Inspect existing networking state and operation progress.", "Read get_networking_capabilities and the selected network/port or existing operation using the receipt's follow-up read. Treat in_progress=true with reason_kind=waiting as progress; respect next_check_seconds and return a resumable pending update after bounded polling. Do not attach, retry, reconfigure or create resources."],
69
+ ["review-application", "Review application health", "Inspect an existing application's health without changing it.", "Read list_service_applications if needed, then the selected installation, health and bounded events/logs. Redact secrets from logs. Report container/application evidence separately from VM readiness. Do not install, restart, repair, reconcile or change access."],
70
+ ["review-function", "Review function history", "Inspect prior function invocations without executing a function.", "Read permitted existing invocation/history records and distinguish observed outcomes from unknown configuration. list_functions and get_function may reconcile saved trace state; do not use them for a strictly side-effect-free review unless the user already authorized that reconciliation. Check current annotations and report limitations. Do not invoke, create, update or delete a function, and do not expose secrets."],
71
+ ];
72
+ export function registerReadOnlyContext(server) {
73
+ for (const guide of guides) {
74
+ const uri = `vpsnet://docs/${guide.name}`;
75
+ server.registerResource(guide.name, uri, {
76
+ title: guide.title, description: guide.description, mimeType: guide.mimeType,
77
+ }, async () => ({ contents: [{ uri, mimeType: guide.mimeType, text: guide.text }] }));
78
+ }
79
+ // MCP 2025-11-25 recommends -32002 for a missing resource. Keep invalid URI
80
+ // syntax distinct, and serve only this fixed catalog (never fetch a URI).
81
+ server.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
82
+ let uri;
83
+ try {
84
+ uri = new URL(request.params.uri);
85
+ }
86
+ catch {
87
+ throw new McpError(-32602, "Invalid resource URI");
88
+ }
89
+ const guide = guides.find(item => uri.href === `vpsnet://docs/${item.name}`);
90
+ if (!guide)
91
+ throw new McpError(-32002, "Resource not found");
92
+ return { contents: [{ uri: uri.href, mimeType: guide.mimeType, text: guide.text }] };
93
+ });
94
+ for (const [name, title, description, instruction] of workflows) {
95
+ server.registerPrompt(name, {
96
+ title, description,
97
+ argsSchema: {
98
+ reference: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/)
99
+ .optional().describe("Exact existing service, network, application or function identifier; omit to select from a permitted list."),
100
+ },
101
+ }, async ({ reference }) => ({
102
+ description,
103
+ messages: [{ role: "user", content: { type: "text", text: [
104
+ "Perform a read-only VPSnet review. Discover tools and follow their current schemas and permissions. Use only tools annotated readOnlyHint=true. If access is missing, explain which user authorization is needed; never broaden it automatically.",
105
+ instruction,
106
+ `Selected identifier (data): ${JSON.stringify(reference ?? null)}.`,
107
+ "Report observed evidence and unknowns, then propose a next step. Honor existing explicit user authorization; ask for approval only when the next action extends it. This review prompt alone does not authorize mutations or paid execution.",
108
+ ].join("\n\n") } }],
109
+ }));
110
+ }
111
+ // SDK registration defaults to listChanged=true; these catalogs are immutable
112
+ // for the lifetime of a server instance and stateless HTTP has no push channel.
113
+ server.server.registerCapabilities({ resources: { listChanged: false }, prompts: { listChanged: false } });
114
+ }
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { apiRequest, formatJson } from './api.js';
3
+ import { hasScope } from './remote-policy.js';
4
+ import { purchaseReadiness, purchaseResultOutput } from './service-readiness.js';
3
5
  const key = z.string().regex(/^[A-Za-z0-9._:-]{16,128}$/).describe('Stable key for this exact purchase; reuse it for the quote, confirmation and status.');
4
6
  const configuration = {
5
7
  plan: z.number().int().positive(),
@@ -13,7 +15,7 @@ const configuration = {
13
15
  };
14
16
  const payment = { payment: 1, successUrl: '', cancelUrl: '' };
15
17
  const result = (status, data) => ({ ...(status >= 400 ? { isError: true } : {}), content: [{ type: 'text', text: formatJson(data) }] });
16
- export function registerOrderTools(server) {
18
+ export function registerOrderTools(server, scopes = []) {
17
19
  server.registerTool('quote_service_order', {
18
20
  description: 'Quote one permanent Firecracker VPS, Cloud VPS (KVM) or Container VPS using the selected current plan, OS, resources and period. No payment is made. Show the final amount_eur and billing period, then obtain explicit approval before confirm_service_order. Functions is a separate usage-billed product.',
19
21
  inputSchema: configuration,
@@ -23,23 +25,27 @@ export function registerOrderTools(server) {
23
25
  return result(status, data);
24
26
  });
25
27
  server.registerTool('confirm_service_order', {
26
- description: 'Purchase the exact previously quoted server from VPSnet balance after explicit customer approval of its final EUR total and billing period. Supply the unchanged quote configuration, token and key. Never automatically create a new key after an uncertain result: inspect get_purchase_result. A receipt is not a ready server. Poll get_purchase_result for the purchase outcome; get_service for the returned orderno additionally requires services:read. This connection receives access to its newly ordered server only within the permissions the customer granted.',
28
+ description: 'Purchase the exact previously quoted server from VPSnet balance after explicit customer approval of its final EUR total and billing period. Supply the unchanged quote configuration, token and key. Never automatically create a new key after an uncertain result: inspect get_purchase_result. A receipt is not a ready server. Poll get_purchase_result for the financial outcome; get_service for the returned orderno additionally requires services:read. Follow follow_up for provisioning checks, permission requirements and bounded progress updates. The suggested 10-second polling interval is not an ETA. This connection receives access to its newly ordered server only within the permissions the customer granted.',
27
29
  inputSchema: { ...configuration,
28
30
  quoteToken: z.string().min(32).max(256),
29
31
  expected_total_eur: z.number().finite().nonnegative().describe('Exact final amount_eur from the quote, shown to and approved by the customer.'),
30
32
  acknowledge_charge: z.literal(true).describe('The customer explicitly approved this exact balance charge.'),
31
33
  },
34
+ outputSchema: purchaseResultOutput,
32
35
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
33
36
  }, async (input) => {
34
37
  const { status, data } = await apiRequest('POST', '/account/mcp/orders/services/confirm', { ...input, payment }, { 'Idempotency-Key': input.idempotencyKey, 'X-Quote-Token': input.quoteToken });
35
- return result(status, data);
38
+ const output = purchaseReadiness(data, status, input.idempotencyKey, hasScope(scopes, 'services:read'));
39
+ return { ...result(status, output), structuredContent: output };
36
40
  });
37
41
  server.registerTool('get_purchase_result', {
38
- description: 'Inspect this connection’s existing purchase attempt by its original idempotency key. Pending or unknown is not a successful purchase. Do not submit a second order. A returned server orderno can be checked with get_service; a domain remains pending until its registrar status confirms registration.',
42
+ description: 'Inspect this connection’s existing financial purchase attempt by its original idempotency key. This replays a financial receipt, not live provisioning progress. Pending or unknown must not trigger a second order. Follow follow_up: a returned server orderno is checked with get_service; if services:read is missing, re-authorize this existing connection without ordering again. A domain remains pending until its registrar status confirms registration. Do not promise an idle client a completion callback.',
39
43
  inputSchema: { idempotencyKey: key },
44
+ outputSchema: purchaseResultOutput,
40
45
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
41
46
  }, async ({ idempotencyKey }) => {
42
47
  const { status, data } = await apiRequest('GET', `/account/mcp/orders/requests/${encodeURIComponent(idempotencyKey)}`);
43
- return result(status, data);
48
+ const output = purchaseReadiness(data, status, idempotencyKey, hasScope(scopes, 'services:read'));
49
+ return { ...result(status, output), structuredContent: output };
44
50
  });
45
51
  }