vpsnet-mcp 2.3.16 → 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.
package/README.md CHANGED
@@ -707,6 +707,8 @@ everything else.
707
707
  | `confirm_application_compose_adoption` | Confirm the exact source takeover after explicit approval | `applications:manage` | free |
708
708
  | `install_application` | Queue a confirmed, version-pinned managed installation | `applications:manage` | free |
709
709
  | `configure_application_access` | Queue a confirmed platform-hostname, private, public-IP, managed-HTTPS, or customer-managed external-HTTPS access change | `applications:manage` | free |
710
+ | `get_application_server_routes` | Read customer-hostname server routes, fallback, revision, per-route state and DNS status | `applications:read` | free |
711
+ | `configure_application_server_routes` | Replace the complete confirmed set of customer-hostname routes to local services (and optional fallback web server) with automatic certificates | `applications:manage` | free |
710
712
  | `configure_application_resource_thresholds` | Replace confirmed non-enforcing application resource thresholds and reached/recovered email preference | `applications:manage` | free |
711
713
  | `manage_application` | Compatibility tool for older clients; use the individual actions below | `applications:manage` | free |
712
714
  | `reconcile_application` | Reconcile an application with its declared configuration | `applications:manage` | free |
@@ -799,6 +801,28 @@ everything else.
799
801
  | `browse_restore_files` | Queue a backup directory listing job without restoring files | `services:read` | free |
800
802
  | `get_restore_file_browse` | Poll a queued backup directory listing (free, read-only) | `services:read` | free |
801
803
 
804
+ ### Service files
805
+ Firecracker servers need the updated guest agent and Cloud VPS (KVM) the VDS file-manager switch; `files:read` / `files:manage` are explicit grants (an empty permission list does not include them).
806
+
807
+ | Tool | Description | Scope | Paid |
808
+ |------|-------------|-------|------|
809
+ | `get_service_file_capabilities` | Check whether the file manager can serve a server now, with the verbatim unavailable reason, limits and transfer mode | `files:read` | free |
810
+ | `list_service_files` | List one directory page inside a running server | `files:read` | free |
811
+ | `stat_service_file` | Describe one entry, optionally with its SHA-256 | `files:read` | free |
812
+ | `read_service_file` | View a text file (up to 1 MiB) or an image (up to 4 MiB), or read a 1 MiB window of any file | `files:read` | free |
813
+ | `search_service_files` | Find entries by name (substring or glob) below a directory | `files:read` | free |
814
+ | `get_service_file_download_url` | Mint a one-file download URL bound to the caller's address | `files:read` | free |
815
+ | `download_service_file` | Download a server file to a local path on the MCP machine, size-checked, with its SHA-256 | `files:read` | free |
816
+ | `get_service_file_operation` | Poll a file operation that answered 202 (slow guest, recursive delete, extraction) | `files:read` | free |
817
+ | `create_service_directory` | Create a directory, optionally with missing parents | `files:manage` | free |
818
+ | `move_service_file` | Rename or move one entry (confirmation required) | `files:manage` | free |
819
+ | `delete_service_files` | **Destructive:** permanently delete files or directories (confirmation required) | `files:manage` | free |
820
+ | `write_service_file` | Write a small file (up to 1 MiB) with compare-and-swap | `files:manage` | free |
821
+ | `upload_service_file` | Upload a local file or base64 content in one call: chunked, resumable transfer, atomic commit and SHA-256 check | `files:manage` | free |
822
+ | `list_service_file_uploads` | List the service's open uploads | `files:manage` | free |
823
+ | `abort_service_file_upload` | Abort one open upload and remove its staging file | `files:manage` | free |
824
+ | `extract_service_file_archive` | Firecracker: extract a zip/tar/tar.gz/.gz archive into a new directory | `files:manage` | free |
825
+
802
826
  ### SSH Keys
803
827
  | Tool | Description | Scope | Paid |
804
828
  |------|-------------|-------|------|
@@ -929,7 +953,7 @@ short-lived quote token.
929
953
  | `list_service_dns_options` | List owned zones and service addresses available for DNS attachment | `dns:read` | free |
930
954
  | `attach_service_dns_record` | Point an owned-zone name at one service address | `dns:write` | free |
931
955
  | `list_dns_zones` | List forward DNS zones owned by the account | `dns:read` | free |
932
- | `create_dns_zone` | Create a native or secondary zone awaiting ownership TXT verification | `dns:write` | free |
956
+ | `create_dns_zone` | Create a zone; qualifying ownership may restore and publish it. Hosted: native only; local stdio: native or secondary | `dns:write` | free |
933
957
  | `get_dns_zone` | Get desired records with IDs and a recoverable ownership TXT value | `dns:read` | free |
934
958
  | `reapply_dns_zone` | Retry publication of a published DNS zone whose apply_state is failed. | `dns:write` | free |
935
959
  | `get_dns_zone_diagnostics` | Inspect delegation, SOA, DNSSEC, and record hygiene | `dns:read` | free |
@@ -1032,7 +1056,8 @@ resource. DNS changes are polled through the network's `settings.dns_change`.
1032
1056
  New private/public load balancers, VPNs and public addresses use an explicit
1033
1057
  quote → common checkout → order-status flow. Quote tools always send
1034
1058
  `prepare_after_payment=true`: no provisioning happens before payment.
1035
- Show the quote's exact EUR `gross_amount`, obtain approval, and pass the same
1059
+ Choose `period_months` only from the live catalog's offered terms (omission is one month).
1060
+ Show the quoted term and exact EUR `gross_amount`, obtain approval, and pass the same
1036
1061
  `idempotencyKey`, `quote_id` and `quoteToken` to `order_network_product`.
1037
1062
  API keys additionally need full access, enabled paid operations,
1038
1063
  `networking:order`, and spend limits. Read `get_network_payment_methods` and
@@ -1060,12 +1085,28 @@ not an alternative to checkout.
1060
1085
  Device configuration pickup is a one-time **management** action, even though
1061
1086
  its HTTP method is GET. Generate the reveal key pair outside MCP, submit only
1062
1087
  the X25519 public key, save the returned sealed ciphertext, and decrypt it
1063
- outside the conversation. Neither private keys nor plaintext WireGuard
1064
- configuration are requested or decrypted by these tools. Never automatically
1088
+ outside the conversation. Neither private keys, plaintext WireGuard configuration nor IPsec PSKs are
1089
+ requested or decrypted by these tools. Router recipes retain a literal PSK
1090
+ placeholder; fill it only in the customer's external configuration workflow. Never automatically
1065
1091
  repeat a pickup after an uncertain response.
1066
1092
 
1067
- The current backend has no customer network rename, existing-port fixed-IP
1068
- edit, appliance retry, or native site-to-site API in this release. Fixed DHCP
1093
+ For personal devices use WireGuard; IPsec IKEv2 is a site-to-site option only.
1094
+ Read the selected gateway's `capabilities.site_ipsec` and `ipsec_profiles`
1095
+ before selecting the protocol, vendor or cipher mode. A listed tool does not
1096
+ prove an older gateway image supports IPsec. Read router recipes with
1097
+ `get_network_vpn_peer_recipe`; don't use a different vendor profile than the
1098
+ one used to create the peer unless the returned API guidance supports it.
1099
+ For IPsec, connected means an established SA and the handshake timestamp is a
1100
+ CHILD-SA rekey; WireGuard uses its recent-handshake status.
1101
+
1102
+ Device `grant_scope=network` authorizes eligible current and future servers in
1103
+ the attached private network, still subject to network security policy. Obtain
1104
+ that broader authorization explicitly. Selected-server access uses
1105
+ `grant_scope=servers`; replacing grants without a scope also selects servers,
1106
+ so inspect and preserve the intended scope when editing an existing device.
1107
+
1108
+ This release exposes no customer network rename, existing-port fixed-IP edit,
1109
+ or appliance restart/rebuild tools. Fixed DHCP
1069
1110
  addresses are selected at attach; removing/recreating an attachment requires
1070
1111
  separate approval and is not represented as an in-place edit. Legacy unpaid
1071
1112
  LB/VPN create routes, legacy stock-before-payment purchases and enabling
@@ -1096,7 +1137,7 @@ feature gate.
1096
1137
  | `attach_private_network_port` | Attach an owned service; omit ipv4 for automatic allocation or pass a fixed usable address | `networking:manage` | free |
1097
1138
  | `detach_private_network_port` | Detach the owned server port | `networking:manage` | free |
1098
1139
  | `retry_private_network_port` | Retry the failed current port operation with the PORT generation and any required restart approval. | `networking:manage` | free |
1099
- | `get_network_vpn_summary` | Read the network's VPN summary, including truthful absence | `networking:read` | free |
1140
+ | `get_network_vpn_summary` | Read the network's VPN summary, including truthful absence; connected locations are managed on the gateway | `networking:read` | free |
1100
1141
  | `get_network_routed_egress` | Read this network's NAT/internet access, public address and qualification state. | `networking:read` | free |
1101
1142
  | `set_network_routed_egress` | Request the currently supported NAT transition for an owned network; the backend may refuse mode changes | `networking:manage` | free |
1102
1143
  | `get_security_group_capabilities` | Read security-group availability and whether the network default policy can be changed. | `networking:read` | free |
@@ -1123,17 +1164,22 @@ feature gate.
1123
1164
  | `add_network_load_balancer_listener` | Add a listener and optional initial members using currently offered protocol capabilities | `networking:manage` | free |
1124
1165
  | `remove_network_load_balancer_listener` | Remove a listener and its members; deleting the last listener is refused. | `networking:manage` | free |
1125
1166
  | `list_network_vpn_gateways` | List owned VPN gateways, their device peers and safe status | `networking:read` | free |
1126
- | `get_network_vpn_gateway` | Read one owned VPN gateway, current generation, device peers, grant candidates and attachment capability. | `networking:read` | free |
1167
+ | `get_network_vpn_gateway` | Read one owned VPN gateway, protocol-specific peer status, IPsec capabilities/profiles, device grant scopes and site options | `networking:read` | free |
1127
1168
  | `delete_network_vpn_gateway` | Request teardown of an owned VPN gateway; all devices lose connectivity | `networking:manage` | free |
1128
1169
  | `attach_network_vpn_gateway` | Attach or move an account VPN to an owned network only when standalone attachment is advertised | `networking:manage` | free |
1129
1170
  | `detach_network_vpn_gateway` | Detach an account VPN from its network when supported; it keeps its paid period and devices but recreates the gateway | `networking:manage` | free |
1130
1171
  | `set_vpn_server_access` | Choose accept or groups_only for the VPN's access to attached servers when this capability is available | `networking:manage` | free |
1131
- | `create_network_vpn_peer` | Add a device with an externally generated one-time PUBLIC reveal key | `networking:manage` | free |
1172
+ | `create_network_vpn_peer` | Add a device, or a connected location (site-to-site office router), with an externally generated one-time PUBLIC reveal key | `networking:manage` | free |
1132
1173
  | `take_network_vpn_peer_config` | Consume the device's sealed configuration ONCE | `networking:manage` | free |
1133
1174
  | `revoke_network_vpn_peer` | Revoke one device using the VPN GATEWAY generation | `networking:manage` | free |
1134
1175
  | `set_network_vpn_peer_grants` | Replace the device's complete reachable-server list when standalone VPN is available | `networking:manage` | free |
1135
1176
  | `get_network_vpn_egress_options` | Read supported VPN Internet exits and qualification | `networking:read` | free |
1136
1177
  | `set_network_vpn_peer_egress` | Set a device's private-only, inherited NAT, or advertised chosen exit policy | `networking:manage` | free |
1178
+ | `get_network_vpn_peer_recipe` | Read public IPsec router instructions with a PSK placeholder; no secret retrieval | `networking:read` | free |
1179
+ | `get_network_vpn_peer_history` | Read a device's or connected location's traffic and handshake history (24 h raw, 7 d, 30 d, 13 months) | `networking:read` | free |
1180
+ | `set_network_vpn_site_options` | Switch hub-and-spoke between the locations connected to a VPN | `networking:manage` | free |
1181
+ | `set_network_vpn_site_alert` | Switch the office-down e-mail of one connected location | `networking:manage` | free |
1182
+ | `set_network_site_route_mode` | Choose DHCP routes or an internal router for a network's routes to connected locations | `networking:manage` | free |
1137
1183
  | `list_network_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. | `networking:read` | free |
1138
1184
  | `list_network_product_periods` | Read owned product periods, renewal availability and lifecycle facts. | `networking:read` | free |
1139
1185
  | `quote_network_product` | Create the network product's exact paid quote for COMMON CHECKOUT | `networking:manage` | Quote: `networking:order` |
package/build/api.js CHANGED
@@ -166,6 +166,50 @@ semantics) {
166
166
  // real cause instead of an unactionable "Unauthorized".
167
167
  return { status: res.status, data: authorizationContext() ? data : annotateAuthFailure(res.status, data) };
168
168
  }
169
+ /**
170
+ * Send raw bytes (an upload chunk) to an account API route with the caller's
171
+ * credentials. Same base, path, credential, redirect and timeout rules as
172
+ * apiRequest; only the request body differs.
173
+ */
174
+ export async function apiRawRequest(method, path, bytes, extraHeaders = {}) {
175
+ validateApiPath(path);
176
+ const url = `${resolveApiBase()}${path}`;
177
+ const headers = {
178
+ Accept: "application/json",
179
+ "Content-Type": "application/octet-stream",
180
+ ...extraHeaders,
181
+ ...apiAuthorization(resolveApiKey),
182
+ };
183
+ const timeoutMs = resolveApiTimeoutMs();
184
+ const controller = new AbortController();
185
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
186
+ let res;
187
+ let text;
188
+ try {
189
+ res = await fetch(url, { method, headers, body: bytes, redirect: "error", signal: controller.signal });
190
+ text = await res.text();
191
+ }
192
+ catch (error) {
193
+ if (error instanceof Error && error.name === "AbortError") {
194
+ throw new Error(`VPSnet API request timed out after ${timeoutMs}ms: ${method} ${path}`);
195
+ }
196
+ throw error;
197
+ }
198
+ finally {
199
+ clearTimeout(timeout);
200
+ }
201
+ let data;
202
+ try {
203
+ data = text ? JSON.parse(text) : null;
204
+ }
205
+ catch {
206
+ return { status: res.status, data: res.ok ? { success: false, error: "The VPSnet API answered with a non-JSON body." }
207
+ : describeNonApiResponse(res.status, res.headers.get("content-type"), text, new URL(url).host, false) };
208
+ }
209
+ if (res.ok === false)
210
+ data = withHttpStatus(res.status, data, res.headers.get("retry-after"));
211
+ return { status: res.status, data: authorizationContext() ? data : annotateAuthFailure(res.status, data) };
212
+ }
169
213
  /**
170
214
  * Structured tool error for a non-2xx answer whose body is not JSON.
171
215
  */
@@ -0,0 +1,11 @@
1
+ /** Describes both creation paths in DnsZonesController::store, including restoration. */
2
+ export function createDnsZoneDescription(hosted = false) {
3
+ return [
4
+ hosted
5
+ ? "Create a native forward DNS zone in the connected VPSnet account. Secondary zones and their TSIG credentials must be configured in the VPSnet control panel; this hosted tool does not accept authentication secrets."
6
+ : "Create a native or secondary forward DNS zone in the authenticated VPSnet account. Secondary zones transfer records from public primary DNS servers and require TSIG.",
7
+ "Normally creates pending_verification. With qualifying current ownership proof, the API may instead restore a recently deleted zone and queue immediate publication. Publication can delete stale zone candidates for the same name, including their DNS records and DDNS tokens. Existing live zones are refused; this does not register a domain or change registrar nameservers.",
8
+ "Read the returned zone.status and apply_state: queued publication is not proof of live DNS service. When verification is needed, verificationRecord contains the public ownership TXT challenge (_vpsnet-dns.<zone>). Follow zone.verification.next_step and warnings for its location, then call verify_dns_zone. Keep nameservers already delegated to VPSnet. A previous-provider or parent-zone location may be required.",
9
+ "If the TXT value is missing, first read get_dns_zone.zone.verification.record.value; reissue_dns_zone_verification rotates it only when unrecoverable or requested by the customer. Requires dns:write.",
10
+ ].join(" ");
11
+ }
package/build/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { installToolInputSchemas } from "./tool-schema.js";
3
+ import { createDnsZoneDescription } from "./dns-zone-contract.js";
3
4
  import { registerOrderTools } from "./remote-orders.js";
4
5
  import { registerRemoteTools } from "./remote-tools.js";
5
6
  import { registerReadOnlyContext } from "./read-only-context.js";
@@ -8,10 +9,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
10
  import { pathToFileURL } from "node:url";
10
11
  import { resolve } from "node:path";
12
+ import { reinstallDescription } from "./service-reinstall-contract.js";
11
13
  import { installRemotePolicy, installToolTitles, remoteInstructions, orderInstructions } from "./remote-policy.js";
12
14
  import { createHash, randomUUID } from "node:crypto";
13
15
  import { readFileSync } from "node:fs";
14
16
  import { registerNetworkingTools } from "./networking-tools.js";
17
+ import { registerServiceFileTools } from "./service-files.js";
15
18
  import { isIP } from "node:net";
16
19
  import { z } from "zod";
17
20
  import { apiRequest, formatJson, initializeStdioApi } from "./api.js";
@@ -50,6 +53,7 @@ export function createVpsnetServer(options = {}) {
50
53
  "Auth: every request authenticates with the X-API-KEY header (your VPSNet API key). Requests are rate-limited — on HTTP 429, back off and retry after a short delay rather than hammering the endpoint.",
51
54
  "This server needs a MANAGEMENT API key (scope full, or read for GET-only use). An AI-scoped key is issued only for VPSnet AI assistant inference and is refused on every account route, so no tool here can work with one. When a tool result carries auth_problem, read its reason and fix and relay them instead of reporting a bare 'Unauthorized'.",
52
55
  "Service tools are scope-gated for API keys: service reads (lists, details, graphs, history, options, backup/snapshot/restore-point listings) require services:read; service mutations (start/stop/restart/console, suspend/resume, hostname, rDNS, root password, title, IPv6, extra settings, SSH key deploy, OS reinstall, plan change, renewal, auto-renew, backups, snapshots, firewall flush) require services:manage plus a full-access key; rescue requires services:rescue; paid whole-service restore additionally requires the services:restore paid scope. A key whose granular scope list was left empty keeps everything its access level (full/read) already allows, so scopes only ever narrow a key.",
56
+ "Service file manager tools (get_service_file_capabilities, list/stat/read/search, download/upload, write, mkdir, move, delete, extract) need an EXPLICIT files:read or files:manage grant on the API key (an empty permission list does not include them; changes need a full-access key). Call get_service_file_capabilities first and relay an unavailable reason verbatim: Firecracker servers need the updated guest agent (agent_update_required) and Cloud VPS (KVM) needs the VDS file-manager switch (feature_disabled). Writes are compare-and-swap: pass the version or sha256 you read. Slow guests are polled inside the tools; continue a returned operationId with get_service_file_operation instead of repeating a change.",
53
57
  "",
54
58
  "## Ordering a new VPS",
55
59
  "Flow: get_order_plans → get_order_options(plan) → order_service.",
@@ -71,6 +75,7 @@ export function createVpsnetServer(options = {}) {
71
75
  "Uninstall permanently deletes the managed containers, configuration, saved credentials, and application data. Existing server backups are retained. Set acknowledge_data_loss=true only after the user explicitly confirms that loss.",
72
76
  "install_application and application lifecycle tools are asynchronous. A queued response is not proof that the application is healthy; poll get_application_installation and inspect get_application_events.",
73
77
  "Use configure_application_access to change an installed application's access mode. Read the installation first and pass its current revision. When access.request.schema_version=2, submit every exact access.endpoints key once and match the user's requested service by endpoint service and port; array order and primary=true are metadata, not a recommendation. platform_https allocates an opaque VPSnet hostname with automatic DNS and HTTPS; private has no public listener; public_http uses the server's public IP over HTTP; managed_https uses a selected VPSnet-managed DNS zone and may add additional_addresses such as the bare domain without www; external_https records a customer-managed HTTPS address and does not configure or validate DNS, TLS, or the customer's reverse proxy.",
78
+ "Server routes send a customer's own hostnames to the customer's own local services on a Managed Applications server (hostname to 127.0.0.1:port, automatic certificate after verified hostname ownership and DNS readiness). Use get_application_server_routes, then configure_application_server_routes with expected_revision and the COMPLETE route set (omitted routes are removed); it needs at least one installed managed application. A customer web server already on ports 80/443 must be pointed at local ports or moved and declared as fallback.",
74
79
  "get_application_installation includes bounded application and per-container resource history plus optional display thresholds when the worker supports those measurements. Use configure_application_resource_thresholds only at the user's request. Thresholds highlight CPU, memory, network-per-minute, or restart-delta samples but do not reserve or enforce resources, trigger server actions, or affect billing. Email is disabled by default; when the user enables it, VPSnet sends one reached and one recovered message per threshold episode without repeating while the metric remains high.",
75
80
  "Use list_application_registry_credentials only for non-secret private registry credential metadata. Exact custom HTTPS registry hostnames are supported. Registry token creation and rotation are intentionally unavailable through MCP because secrets must not enter model prompts or tool arguments; use the VPSnet panel or direct REST API.",
76
81
  "Customer recipes are customer-owned Compose definitions, distinct from VPSnet catalog blueprints. Validation resolves mutable image tags once and returns an immutable digest-pinned Compose definition before checking it on the target worker. Recipe creation and revision tools freeze that exact validated definition; installation runs an exact revision. Export is available only for customer recipes and never for VPSnet catalog recipes.",
@@ -169,6 +174,8 @@ export function createVpsnetServer(options = {}) {
169
174
  if (options.remoteScopes)
170
175
  installRemotePolicy(server, options.remoteScopes, options.remoteProfile);
171
176
  registerNetworkingTools(server);
177
+ // Local-path transfers and address-bound download URLs stay on stdio; the hosted connector gets the API-only file tools.
178
+ registerServiceFileTools(server, undefined, options.remoteScopes ? "remote" : "stdio");
172
179
  // Helper to build service settings path
173
180
  const svc = (orderNo, action) => `/account/services/${encodeURIComponent(serviceOrderNoSchema.parse(orderNo))}/${action}`;
174
181
  const idempotencyKeySchema = z
@@ -1439,6 +1446,90 @@ export function createVpsnetServer(options = {}) {
1439
1446
  ],
1440
1447
  };
1441
1448
  });
1449
+ server.registerTool("get_application_server_routes", {
1450
+ description: "Get the server routes of one owned Managed Applications server: customer-owned hostnames routed by the in-guest HTTPS front door to the customer's own local services (for example app.example.com to 127.0.0.1:3000) with an automatic certificate, the optional fallback (the customer's own web server on local ports), the current revision, applied revision, overall state, per-route state and DNS status (ok, missing, elsewhere, unknown, with the A/AAAA records to create), availability and limits. Read this before configure_application_server_routes and pass its revision as expected_revision. Requires applications:read.",
1451
+ inputSchema: {
1452
+ orderNo: applicationOrderNoSchema,
1453
+ },
1454
+ annotations: {
1455
+ title: "Get application server routes",
1456
+ readOnlyHint: true,
1457
+ destructiveHint: false,
1458
+ idempotentHint: true,
1459
+ },
1460
+ }, async ({ orderNo }) => {
1461
+ const { data } = await apiRequest("GET", applicationPath(orderNo, "server-routes"));
1462
+ return { content: [{ type: "text", text: formatJson(data) }] };
1463
+ });
1464
+ const serverRouteHostnameSchema = z
1465
+ .string()
1466
+ .min(1)
1467
+ .max(253)
1468
+ .describe("Customer-owned hostname, for example app.example.com");
1469
+ const serverRoutePortSchema = z
1470
+ .number()
1471
+ .int()
1472
+ .min(1024)
1473
+ .max(65535)
1474
+ .refine((port) => ![10080, 10443, 19090].includes(port), {
1475
+ message: "Ports 10080, 10443 and 19090 are reserved",
1476
+ });
1477
+ server.registerTool("configure_application_server_routes", {
1478
+ description: "Queue a change of the server routes of one owned Managed Applications server. This REPLACES the COMPLETE set: read get_application_server_routes first, then send the full list of wanted routes and the fallback; a route or fallback that is omitted is removed. Each route sends one customer hostname to 127.0.0.1:<port> on the same server over http or https (ports 1024-65535 except 10080, 10443 and 19090; no host field, upstream is always local; at most 64 routes). The hostname needs verified ownership evidence as well as DNS pointing at the server; pointing DNS alone does not prove ownership. get_application_server_routes reports DNS, per-route state/errors and records to create. Preserve backend refusals, including applicationServerRoutesDeliveredCertificates when delivered certificates prevent enabling routes. Removed hostname claims are not released until the change is applied. The server must have at least one installed managed application. Unknown hostnames are never sent to an application (HTTP 421 / TLS unrecognized_name). Coexistence: if the customer runs their own web server (Caddy, nginx, Traefik) on ports 80/443, either point it at local ports instead, or move it to local ports and declare it as fallback ({http_port?, https_port?}); the fallback receives every hostname the front door does not serve (HTTP proxied with its Host header, HTTPS passed through unterminated). Pass expected_revision from the read; a stale revision is refused with applicationServerRoutesRevisionConflict. Confirm the exact full set with the user first. A queued response (202, action type configure_routes) must be verified by polling get_application_server_routes until state is active. Requires applications:manage and is not a paid API-key operation.",
1479
+ inputSchema: {
1480
+ orderNo: applicationOrderNoSchema,
1481
+ expected_revision: z
1482
+ .number()
1483
+ .int()
1484
+ .min(0)
1485
+ .max(Number.MAX_SAFE_INTEGER)
1486
+ .describe("Current revision returned by get_application_server_routes"),
1487
+ routes: z
1488
+ .array(z.object({
1489
+ hostname: serverRouteHostnameSchema,
1490
+ upstream: z.object({
1491
+ scheme: z.enum(["http", "https"]),
1492
+ port: serverRoutePortSchema,
1493
+ }),
1494
+ }))
1495
+ .max(64)
1496
+ .describe("The COMPLETE wanted set of routes; omitting a route removes it. An empty array removes all routes."),
1497
+ fallback: z
1498
+ .object({
1499
+ http_port: serverRoutePortSchema.optional(),
1500
+ https_port: serverRoutePortSchema.optional(),
1501
+ })
1502
+ .nullable()
1503
+ .optional()
1504
+ .describe("The customer's own web server on local ports, receiving every hostname the front door does not serve. null or omitted removes the fallback."),
1505
+ idempotencyKey: idempotencyKeySchema.describe("Client-global unique key. Reuse it only to replay this exact request; never reuse it for another service or request."),
1506
+ confirmed: z
1507
+ .literal(true)
1508
+ .describe("True only after the user confirmed the complete route set"),
1509
+ },
1510
+ annotations: {
1511
+ title: "Configure application server routes",
1512
+ readOnlyHint: false,
1513
+ destructiveHint: true,
1514
+ idempotentHint: true,
1515
+ openWorldHint: true,
1516
+ },
1517
+ }, async ({ orderNo, expected_revision, routes, fallback, idempotencyKey, confirmed, }) => {
1518
+ const { status, data } = await apiRequest("PUT", applicationPath(orderNo, "server-routes"), {
1519
+ expectedRevision: expected_revision,
1520
+ routes,
1521
+ fallback: fallback ?? null,
1522
+ confirmed,
1523
+ }, { "Idempotency-Key": idempotencyKey });
1524
+ return {
1525
+ content: [
1526
+ {
1527
+ type: "text",
1528
+ text: safeApplicationMutationResult(status, data, orderNo),
1529
+ },
1530
+ ],
1531
+ };
1532
+ });
1442
1533
  async function performApplicationLifecycleAction({ orderNo, installation_id, action, idempotencyKey, expected_blueprint_version, expected_upstream_version, acknowledge_data_loss, }) {
1443
1534
  if (action === "uninstall" && acknowledge_data_loss !== true) {
1444
1535
  return {
@@ -1836,18 +1927,18 @@ export function createVpsnetServer(options = {}) {
1836
1927
  return { content: [{ type: "text", text: formatJson(data) }] };
1837
1928
  });
1838
1929
  server.registerTool("reinstall_os", {
1839
- description: "Reinstall OS on a VPS or qualified dedicated server. WARNING: destroys existing data; confirm with the user before reinstalling. Cloud VPS and Firecracker VPS: reinstall also deletes existing snapshots, so they cannot provide rollback after reinstall. Read get_os_options.snapshotDeletion first; when snapshots exist, set confirmSnapshotDelete to true only after the user explicitly confirms their deletion. A pending snapshot action blocks reinstall until it finishes. Container VPS has no snapshots. Dedicated servers erase every disk and have no snapshot rollback; get_os_options reports dedicated=true and confirmRequired=true. Pass confirmReinstall=true only after the user explicitly approves reinstalling that exact dedicated server and losing all disk data. Dedicated reinstall supports an optional sshKey from list_ssh_keys (otherwise the prior install key is reused), never rootPassword, and returns a reinstall job ID. VPS reinstall returns a noty UUID for acceptance; follow get_service_history for completion. Password rules: 6-40 chars, alphanumeric, must contain uppercase + lowercase + digit. OAuth requires services:reinstall; API keys require full access.",
1930
+ description: reinstallDescription(false),
1840
1931
  inputSchema: {
1841
1932
  orderNo: serviceOrderNoSchema,
1842
1933
  confirmReinstall: z.boolean().optional().describe("Dedicated only: true only after explicit user approval to reinstall this exact server and erase all its disks; never inferred or defaulted."),
1843
- sshKey: positiveIdSchema.optional().describe("Dedicated only: account SSH key ID from list_ssh_keys; omitted reuses the installed key."),
1934
+ sshKey: positiveIdSchema.optional().describe("Account public SSH key ID from list_ssh_keys. Mutually exclusive with rootPassword. Dedicated: omitted reuses the installed key when one exists."),
1844
1935
  osVersion: z
1845
1936
  .number()
1846
1937
  .describe("OS version ID from get_os_options"),
1847
1938
  rootPassword: z
1848
1939
  .string()
1849
1940
  .optional()
1850
- .describe("New root password (auto-generated if omitted). 6-40 chars, alphanumeric, must contain uppercase + lowercase + digit"),
1941
+ .describe("New root password for VPS or dedicated reinstall; mutually exclusive with sshKey. Omit to reuse a dedicated server's installed key when available, otherwise the backend generates a password. 6-40 chars, alphanumeric, uppercase + lowercase + digit."),
1851
1942
  confirmSnapshotDelete: z
1852
1943
  .boolean()
1853
1944
  .optional()
@@ -1859,6 +1950,8 @@ export function createVpsnetServer(options = {}) {
1859
1950
  idempotentHint: false,
1860
1951
  },
1861
1952
  }, async ({ orderNo, osVersion, rootPassword, confirmSnapshotDelete, confirmReinstall, sshKey }) => {
1953
+ if (rootPassword !== undefined && sshKey !== undefined)
1954
+ throw new Error("Choose an account sshKey or rootPassword, not both.");
1862
1955
  const options = await apiRequest("GET", svc(orderNo, "change-os"));
1863
1956
  const capability = options.data;
1864
1957
  if (options.status >= 400 || capability?.success !== true) {
@@ -1868,15 +1961,13 @@ export function createVpsnetServer(options = {}) {
1868
1961
  if (capability.dedicated === true) {
1869
1962
  if (confirmReinstall !== true)
1870
1963
  throw new Error("Dedicated reinstall requires explicit user approval of all-disk data loss and confirmReinstall=true.");
1871
- if (rootPassword !== undefined)
1872
- throw new Error("Dedicated reinstall does not support rootPassword; use an account sshKey or reuse the installed key.");
1873
1964
  body.confirmReinstall = true;
1874
- if (sshKey !== undefined)
1875
- body.sshKey = sshKey;
1876
1965
  }
1877
- else if (confirmReinstall !== undefined || sshKey !== undefined) {
1878
- throw new Error("confirmReinstall and sshKey are dedicated-only reinstall fields; inspect get_os_options for this service.");
1966
+ else if (confirmReinstall !== undefined) {
1967
+ throw new Error("confirmReinstall is a dedicated-only reinstall field; inspect get_os_options for this service.");
1879
1968
  }
1969
+ if (sshKey !== undefined)
1970
+ body.sshKey = sshKey;
1880
1971
  if (rootPassword)
1881
1972
  body.rootPassword = rootPassword;
1882
1973
  if (confirmSnapshotDelete === true)
@@ -3098,7 +3189,7 @@ export function createVpsnetServer(options = {}) {
3098
3189
  return { content: [{ type: "text", text: formatJson(data) }] };
3099
3190
  });
3100
3191
  server.registerTool("get_domain", {
3101
- description: "Get one owned domain with current nameservers and any pending domain action. Requires domains:read when using an API key.",
3192
+ description: "Get one owned domain with current nameservers and any pending domain action. An automatic DNSSEC move may report dnssec_transition steps with waiting/retrying states; queued or waiting is not completed. Requires domains:read when using an API key.",
3102
3193
  inputSchema: {
3103
3194
  domain_id: positiveIdSchema.describe("Owned domain ID from list_domains"),
3104
3195
  },
@@ -3114,7 +3205,7 @@ export function createVpsnetServer(options = {}) {
3114
3205
  return { content: [{ type: "text", text: formatJson(data) }] };
3115
3206
  });
3116
3207
  server.registerTool("set_domain_nameservers", {
3117
- description: "Queue an asynchronous registrar nameserver change for an owned domain. When applied, the parent zone delegates the domain to the new public nameservers, replacing the prior delegation. Nameservers must be hostnames, not IP addresses; same-domain nameserver IP records are managed separately. This does not manage DNS records or PTR. Requires domains:manage when using an API key.",
3208
+ description: "Queue an asynchronous registrar nameserver change for an owned domain. When applied, the parent zone delegates the domain to the new public nameservers, replacing the prior delegation. Nameservers must be hostnames, not IP addresses; same-domain nameserver IP records are managed separately. This does not manage DNS records or PTR. Follow returned dnssec_transition steps until done; preserve parentDsUnreadable or parentDsRemovalRequired refusals and do not bypass an active transition with manual DS edits. Requires domains:manage when using an API key.",
3118
3209
  inputSchema: {
3119
3210
  domain_id: positiveIdSchema.describe("Owned domain ID from list_domains"),
3120
3211
  nameservers: z.array(nameserverHostnameSchema).min(2).max(13).describe("2-13 nameserver hostnames, e.g. ns1.vpsnet.com, ns2.vpsnet.com. IP addresses are not accepted."),
@@ -4202,7 +4293,7 @@ export function createVpsnetServer(options = {}) {
4202
4293
  return { content: [{ type: "text", text: formatJson(data) }] };
4203
4294
  });
4204
4295
  server.registerTool("create_dns_zone", {
4205
- description: "Create a forward DNS zone in pending verification state. Native zones are managed here; secondary zones AXFR from public primary DNS servers and require TSIG. Returns the ownership TXT in verificationRecord (name _vpsnet-dns.<zone>). If the value is lost, read get_dns_zone.zone.verification.record.value first; use reissue_dns_zone_verification only when it is null/unrecoverable or the customer requests rotation. The API records the nameservers the parent zone delegates the name to, so the TXT can still be proven at that previous provider after the domain is pointed at VPSnet. warnings: delegatedToPlatform (the domain already points at VPSnet nameservers - keep them, do not point it back) or parentServedByPlatform (the TXT goes in the parent zone); zone.verification.next_step says what to do next. Add the TXT at that location, then call verify_dns_zone. Requires dns:write when using an API key.",
4296
+ description: createDnsZoneDescription(),
4206
4297
  inputSchema: {
4207
4298
  zone: z.string().describe("Zone name, e.g. example.com"),
4208
4299
  kind: dnsZoneKindSchema
@@ -4227,7 +4318,7 @@ export function createVpsnetServer(options = {}) {
4227
4318
  },
4228
4319
  annotations: {
4229
4320
  readOnlyHint: false,
4230
- destructiveHint: false,
4321
+ destructiveHint: true,
4231
4322
  openWorldHint: true,
4232
4323
  },
4233
4324
  }, async ({ zone, kind, secondary_masters, secondary_tsig_key_name, secondary_tsig_algorithm, secondary_tsig_secret }) => {
@@ -4334,13 +4425,19 @@ export function createVpsnetServer(options = {}) {
4334
4425
  return { content: [{ type: "text", text: formatJson(data) }] };
4335
4426
  });
4336
4427
  server.registerTool("delete_dns_zone", {
4337
- description: "Delete a forward DNS zone. Published zones are queued for removal from the managed DNS platform. Requires dns:write when using an API key.",
4428
+ description: "Delete a forward DNS zone. Published zones are queued for removal from the managed DNS platform; the zone's own records are kept on the deletion record, so re-creating the same name in the same account within 7 days publishes it again with those records. A published zone that the domain is delegated to VPSnet for (get_dns_zone: delegation.state=platform) answers for the whole internet: deleting it stops the domain resolving worldwide (websites, e-mail). The API refuses such a delete with HTTP 409 deleteRequiresConfirmation (reason zone_serves_domain, confirm_field confirm_outage, the delegated nameservers) and deletes nothing. Tell the user exactly that and call again with confirm_outage=true only after the user explicitly confirmed the outage; never set it by default or infer it. Requires dns:write when using an API key.",
4338
4429
  inputSchema: {
4339
4430
  zone_id: positiveIdSchema.describe("DNS zone ID"),
4431
+ confirm_outage: z
4432
+ .boolean()
4433
+ .optional()
4434
+ .default(false)
4435
+ .describe("true only after the user explicitly confirmed that deleting this zone stops the domain resolving worldwide; required when the API answered deleteRequiresConfirmation. Default false."),
4340
4436
  },
4341
4437
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
4342
- }, async ({ zone_id }) => {
4343
- const { data } = await apiRequest("DELETE", `/account/dns/zones/${encodeURIComponent(String(zone_id))}`);
4438
+ }, async ({ zone_id, confirm_outage }) => {
4439
+ const query = confirm_outage === true ? "?confirm_outage=true" : "";
4440
+ const { data } = await apiRequest("DELETE", `/account/dns/zones/${encodeURIComponent(String(zone_id))}${query}`);
4344
4441
  return { content: [{ type: "text", text: formatJson(data) }] };
4345
4442
  });
4346
4443
  server.registerTool("reapply_dns_zone", {