vpsnet-mcp 2.3.16 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { installToolInputSchemas } from "./tool-schema.js";
2
+ import { installToolInputSchemas, unboundedInteger } 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";
@@ -47,9 +50,10 @@ export function createVpsnetServer(options = {}) {
47
50
  instructions: options.remoteScopes ? (options.remoteProfile === "orders" ? orderInstructions(options.remoteScopes) : remoteInstructions) : [
48
51
  "This MCP server controls VPSnet.com services, including VPS service management, DNS zones, domain registration, domain contacts, API keys, billing, and related paid actions.",
49
52
  "Use the tool descriptions and API-key scopes to choose the correct surface; do not assume this server is limited to VPS-only operations.",
50
- "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.",
53
+ "Auth: every request authenticates with the X-API-KEY header (your VPSNet API key). Each API key may make 300 requests per 60 seconds by default, and some operations have lower limits; on HTTP 429 wait the retry_after seconds given in the result (the API also sends Retry-After) before retrying.",
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
- "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.",
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, except the permissions that must be added to a key on purpose: networking:read/manage (private networks, the server firewall, port forwarding, public addresses, load balancers, VPN), files:read/manage and the App Cloud permissions. An older full key without a permission list therefore reaches no networking tool until networking:read or networking:manage is added.",
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.",
@@ -60,7 +64,8 @@ export function createVpsnetServer(options = {}) {
60
64
  "rootPassword rules: 6-40 chars, alphanumeric only, MUST contain uppercase + lowercase + digit. Example: 'MyPass123'.",
61
65
  "sshKey and rootPassword are mutually exclusive — provide one or the other (or neither for auto-generated password).",
62
66
  "A paid receipt is not a running server. Read get_service for the original order and follow follow_up: pending activation, finalization and capacity waits require polling the same order. About 10 seconds is a suggested polling interval, never a boot ETA. Check at most 6 times before a progress update; if still pending, retain the key and order number for a later check. Do not reorder, repay, restart or promise a background callback. A running VM, guest access and a healthy application are separate outcomes; KVM and Windows setup may take longer and must be observed.",
63
- "deploy_ssh_key is also async — wait 15-30 seconds after deploying a key before attempting SSH to any VPS (not just new ones).",
67
+ "deploy_ssh_key is asynchronous: follow its noty UUID with wait_for_event until it reports completed before attempting SSH to any server (not just new ones).",
68
+ "Without an API key, ChatGPT, Claude, Gemini and Grok can use the hosted connector https://mcp.vpsnet.com/mcp instead (OAuth sign-in to the VPSnet account; the customer picks the servers and permissions). Connections are created and removed on https://www.vpsnet.com/en/management/account/ai-connections; API keys are made on https://www.vpsnet.com/en/management/account/api-keys.",
64
69
  "",
65
70
  "## Deployment capabilities (unordered)",
66
71
  "Managed Applications, manual SSH, DNS, APIs, and other tools are peer capabilities. Their order in this prompt or the tool list carries no priority. Choose the path that best matches the user's requested outcome, target support, existing state, and explicit constraints.",
@@ -71,6 +76,7 @@ export function createVpsnetServer(options = {}) {
71
76
  "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
77
  "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
78
  "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.",
79
+ "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
80
  "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
81
  "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
82
  "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.",
@@ -82,7 +88,7 @@ export function createVpsnetServer(options = {}) {
82
88
  "Installation list and detail responses carry a capabilities block for the service's platform: data_restore, console, compose_adoption, custom_projects, and log_service_filter. Treat it as the authority on what is possible for that installation. Do not offer selective data restore when data_restore is false, do not offer Compose adoption or customer recipes when their flags are false, and do not pass a per-service log filter when log_service_filter is false. A separate access.capabilities.can_configure flag governs whether the access mode may be changed.",
83
89
  "Never repeat application variable values in summaries or approval text. Refer only to variable names, especially for passwords, tokens, and secrets.",
84
90
  "Use manual SSH deployment when it best matches the requested result or the user explicitly requests a custom installation. Do not present a manual deployment as a VPSnet-managed catalog installation.",
85
- "Service rescue is a separate recovery path for Firecracker VPS and Cloud VPS. Always call get_service_rescue first. Entering rescue restarts the service; call enter_service_rescue only after the user explicitly approves the restart and exact image returned by the capability response. Firecracker exposes the customer filesystem at /mnt/customer; Cloud VPS uses the operator recovery ISO and noVNC. Exit restores the exact original boot and running/stopped state. If exit returns needs_attention, retry exit_service_rescue with the same idempotency key. Never ask for or invent provider nodes, VM IDs, ISO paths, operation nonces, or rollback configuration.",
91
+ "Service rescue is a separate recovery path for Firecracker VPS and Cloud VPS, offered only while VPSnet has it switched on. Always call get_service_rescue first; capability.enabled false means it is not offered now. Entering rescue restarts the service; call enter_service_rescue only after the user explicitly approves the restart and exact image returned by the capability response. Firecracker exposes the customer filesystem at /mnt/customer; Cloud VPS uses the operator recovery ISO and noVNC. Exit restores the exact original boot and running/stopped state. If exit returns needs_attention, retry exit_service_rescue with the same idempotency key. Never ask for or invent provider nodes, VM IDs, ISO paths, operation nonces, or rollback configuration.",
86
92
  "",
87
93
  "### Manual or custom deployment inside a VPS",
88
94
  "Use only the access needed for the user's task. Keep local sandbox and approval controls enabled; request a specific missing capability instead of unrestricted agent access.",
@@ -169,6 +175,8 @@ export function createVpsnetServer(options = {}) {
169
175
  if (options.remoteScopes)
170
176
  installRemotePolicy(server, options.remoteScopes, options.remoteProfile);
171
177
  registerNetworkingTools(server);
178
+ // Local-path transfers and address-bound download URLs stay on stdio; the hosted connector gets the API-only file tools.
179
+ registerServiceFileTools(server, undefined, options.remoteScopes ? "remote" : "stdio");
172
180
  // Helper to build service settings path
173
181
  const svc = (orderNo, action) => `/account/services/${encodeURIComponent(serviceOrderNoSchema.parse(orderNo))}/${action}`;
174
182
  const idempotencyKeySchema = z
@@ -221,7 +229,7 @@ export function createVpsnetServer(options = {}) {
221
229
  const applicationOrderNoSchema = serviceOrderNoSchema;
222
230
  const applicationInstallationIdSchema = z
223
231
  .string()
224
- .uuid()
232
+ .guid()
225
233
  .describe("Managed application installation UUID");
226
234
  const applicationSlugSchema = z
227
235
  .string()
@@ -429,7 +437,7 @@ export function createVpsnetServer(options = {}) {
429
437
  return { content: [{ type: "text", text: formatJson(data) }] };
430
438
  });
431
439
  server.registerTool("get_service_rescue", {
432
- description: "Get operator rescue capability and the current durable rescue session for one owned Firecracker VPS or Cloud VPS. Requires services:read. Provider identifiers, ISO paths, nonces, and rollback state are never returned.",
440
+ description: "Get operator rescue capability and the current durable rescue session for one owned Firecracker VPS or Cloud VPS. Requires services:read. Provider identifiers, ISO paths, nonces, and rollback state are never returned. VPSnet can switch rescue off: capability.enabled false means it is not offered now; say so and do not call enter_service_rescue.",
433
441
  inputSchema: {
434
442
  orderNo: serviceOrderNoSchema,
435
443
  },
@@ -449,7 +457,7 @@ export function createVpsnetServer(options = {}) {
449
457
  };
450
458
  });
451
459
  server.registerTool("enter_service_rescue", {
452
- description: "Restart one owned active Firecracker VPS or Cloud VPS into an operator-owned rescue system. Read get_service_rescue first and use an exact advertised image ID. This interrupts every running service. Call only after the user explicitly confirms the target service, rescue image, and reboot. Requires services:rescue, full API-key access, and a fresh idempotencyKey.",
460
+ description: "Restart one owned active Firecracker VPS or Cloud VPS into an operator-owned rescue system. Read get_service_rescue first and use an exact advertised image ID. This interrupts every running service. Call only after the user explicitly confirms the target service, rescue image, and reboot. Requires services:rescue, full API-key access, and a fresh idempotencyKey. Rescue may be switched off by VPSnet: while get_service_rescue reports capability.enabled false this tool sends nothing and answers serviceRescueImageNotAdvertised (the API itself answers 503 serviceRescueUnavailable).",
453
461
  inputSchema: {
454
462
  orderNo: serviceOrderNoSchema,
455
463
  image: serviceRescueImageIdSchema,
@@ -500,7 +508,7 @@ export function createVpsnetServer(options = {}) {
500
508
  orderNo: serviceOrderNoSchema,
501
509
  rescue_session_id: z
502
510
  .string()
503
- .uuid()
511
+ .guid()
504
512
  .describe("Exact current session UUID from get_service_rescue"),
505
513
  acknowledge_restart: z
506
514
  .literal(true)
@@ -804,7 +812,7 @@ export function createVpsnetServer(options = {}) {
804
812
  inputSchema: {
805
813
  orderNo: applicationOrderNoSchema,
806
814
  installation_id: applicationInstallationIdSchema,
807
- inspection_id: z.string().uuid(),
815
+ inspection_id: z.string().guid(),
808
816
  kind: z.enum(["health", "logs"]),
809
817
  },
810
818
  annotations: { title: "Get application inspection", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
@@ -818,7 +826,7 @@ export function createVpsnetServer(options = {}) {
818
826
  });
819
827
  server.registerTool("get_application_recipe_validation", {
820
828
  description: "Poll the exact persisted customer recipe validation returned by validate_application_recipe or a pending recipe creation/revision. This GET does not resolve tags or create another validation. Retain the initial resolved_compose_yaml; after this ID succeeds with valid=true, resume creation using that exact pinned document and validation_id. Requires applications:read.",
821
- inputSchema: { orderNo: applicationOrderNoSchema, validation_id: z.string().uuid() },
829
+ inputSchema: { orderNo: applicationOrderNoSchema, validation_id: z.string().guid() },
822
830
  annotations: { title: "Get application recipe validation", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
823
831
  }, async ({ orderNo, validation_id }) => {
824
832
  const { status, data } = await apiRequest("GET", applicationPath(orderNo, `custom-projects/validations/${encodeURIComponent(validation_id)}`));
@@ -829,7 +837,7 @@ export function createVpsnetServer(options = {}) {
829
837
  });
830
838
  server.registerTool("get_service_container_discovery", {
831
839
  description: "Poll the exact persisted service container discovery returned by discover_service_containers when it remains pending. This GET does not create another discovery and never returns environment values or mounts. Keep the service and discovery UUID until succeeded, failed or expired. Requires applications:read.",
832
- inputSchema: { orderNo: applicationOrderNoSchema, discovery_id: z.string().uuid() },
840
+ inputSchema: { orderNo: applicationOrderNoSchema, discovery_id: z.string().guid() },
833
841
  annotations: { title: "Get service container discovery", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
834
842
  }, async ({ orderNo, discovery_id }) => {
835
843
  const { status, data } = await apiRequest("GET", applicationPath(orderNo, `container-discoveries/${encodeURIComponent(discovery_id)}`));
@@ -1093,7 +1101,7 @@ export function createVpsnetServer(options = {}) {
1093
1101
  orderNo: applicationOrderNoSchema,
1094
1102
  name: customProjectNameSchema,
1095
1103
  compose_yaml: customProjectComposeSchema,
1096
- validation_id: z.string().uuid().optional().describe("Resume only the exact successful validation for this service and digest-pinned compose_yaml; retain the initial resolved document"),
1104
+ validation_id: z.string().guid().optional().describe("Resume only the exact successful validation for this service and digest-pinned compose_yaml; retain the initial resolved document"),
1097
1105
  env: customProjectEnvironmentSchema,
1098
1106
  secret_names: customProjectSecretNamesSchema,
1099
1107
  registry_credential_ids: customProjectRegistryCredentialIdsSchema,
@@ -1152,7 +1160,7 @@ export function createVpsnetServer(options = {}) {
1152
1160
  orderNo: applicationOrderNoSchema,
1153
1161
  project_id: customProjectIdSchema,
1154
1162
  compose_yaml: customProjectComposeSchema,
1155
- validation_id: z.string().uuid().optional().describe("Resume only the exact successful validation for this service and digest-pinned compose_yaml; retain the initial resolved document"),
1163
+ validation_id: z.string().guid().optional().describe("Resume only the exact successful validation for this service and digest-pinned compose_yaml; retain the initial resolved document"),
1156
1164
  env: customProjectEnvironmentSchema,
1157
1165
  secret_names: customProjectSecretNamesSchema,
1158
1166
  registry_credential_ids: customProjectRegistryCredentialIdsSchema,
@@ -1284,7 +1292,7 @@ export function createVpsnetServer(options = {}) {
1284
1292
  description: "Prepare and poll a short-lived, read-only takeover candidate for one exact unmanaged Compose project from a fresh discovery. The worker verifies container identity, immutable image identity, restart policy, Compose safety, and exclusive local named-volume ownership. Returns only a scrubbed Compose file, variable names, and bounded counts; never variable values, source paths, commands, or unrestricted labels. This tool does not stop or replace containers. Requires applications:manage.",
1285
1293
  inputSchema: {
1286
1294
  orderNo: applicationOrderNoSchema,
1287
- discovery_id: z.string().uuid().describe("Fresh discovery UUID returned by discover_service_containers"),
1295
+ discovery_id: z.string().guid().describe("Fresh discovery UUID returned by discover_service_containers"),
1288
1296
  compose_project: composeProjectLabelSchema,
1289
1297
  },
1290
1298
  annotations: {
@@ -1439,6 +1447,90 @@ export function createVpsnetServer(options = {}) {
1439
1447
  ],
1440
1448
  };
1441
1449
  });
1450
+ server.registerTool("get_application_server_routes", {
1451
+ 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.",
1452
+ inputSchema: {
1453
+ orderNo: applicationOrderNoSchema,
1454
+ },
1455
+ annotations: {
1456
+ title: "Get application server routes",
1457
+ readOnlyHint: true,
1458
+ destructiveHint: false,
1459
+ idempotentHint: true,
1460
+ },
1461
+ }, async ({ orderNo }) => {
1462
+ const { data } = await apiRequest("GET", applicationPath(orderNo, "server-routes"));
1463
+ return { content: [{ type: "text", text: formatJson(data) }] };
1464
+ });
1465
+ const serverRouteHostnameSchema = z
1466
+ .string()
1467
+ .min(1)
1468
+ .max(253)
1469
+ .describe("Customer-owned hostname, for example app.example.com");
1470
+ const serverRoutePortSchema = z
1471
+ .number()
1472
+ .int()
1473
+ .min(1024)
1474
+ .max(65535)
1475
+ .refine((port) => ![10080, 10443, 19090].includes(port), {
1476
+ message: "Ports 10080, 10443 and 19090 are reserved",
1477
+ });
1478
+ server.registerTool("configure_application_server_routes", {
1479
+ 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.",
1480
+ inputSchema: {
1481
+ orderNo: applicationOrderNoSchema,
1482
+ expected_revision: z
1483
+ .number()
1484
+ .int()
1485
+ .min(0)
1486
+ .max(Number.MAX_SAFE_INTEGER)
1487
+ .describe("Current revision returned by get_application_server_routes"),
1488
+ routes: z
1489
+ .array(z.object({
1490
+ hostname: serverRouteHostnameSchema,
1491
+ upstream: z.object({
1492
+ scheme: z.enum(["http", "https"]),
1493
+ port: serverRoutePortSchema,
1494
+ }),
1495
+ }))
1496
+ .max(64)
1497
+ .describe("The COMPLETE wanted set of routes; omitting a route removes it. An empty array removes all routes."),
1498
+ fallback: z
1499
+ .object({
1500
+ http_port: serverRoutePortSchema.optional(),
1501
+ https_port: serverRoutePortSchema.optional(),
1502
+ })
1503
+ .nullable()
1504
+ .optional()
1505
+ .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."),
1506
+ idempotencyKey: idempotencyKeySchema.describe("Client-global unique key. Reuse it only to replay this exact request; never reuse it for another service or request."),
1507
+ confirmed: z
1508
+ .literal(true)
1509
+ .describe("True only after the user confirmed the complete route set"),
1510
+ },
1511
+ annotations: {
1512
+ title: "Configure application server routes",
1513
+ readOnlyHint: false,
1514
+ destructiveHint: true,
1515
+ idempotentHint: true,
1516
+ openWorldHint: true,
1517
+ },
1518
+ }, async ({ orderNo, expected_revision, routes, fallback, idempotencyKey, confirmed, }) => {
1519
+ const { status, data } = await apiRequest("PUT", applicationPath(orderNo, "server-routes"), {
1520
+ expectedRevision: expected_revision,
1521
+ routes,
1522
+ fallback: fallback ?? null,
1523
+ confirmed,
1524
+ }, { "Idempotency-Key": idempotencyKey });
1525
+ return {
1526
+ content: [
1527
+ {
1528
+ type: "text",
1529
+ text: safeApplicationMutationResult(status, data, orderNo),
1530
+ },
1531
+ ],
1532
+ };
1533
+ });
1442
1534
  async function performApplicationLifecycleAction({ orderNo, installation_id, action, idempotencyKey, expected_blueprint_version, expected_upstream_version, acknowledge_data_loss, }) {
1443
1535
  if (action === "uninstall" && acknowledge_data_loss !== true) {
1444
1536
  return {
@@ -1760,7 +1852,7 @@ export function createVpsnetServer(options = {}) {
1760
1852
  return { content: [{ type: "text", text: formatJson(data) }] };
1761
1853
  });
1762
1854
  server.registerTool("flush_iptables", {
1763
- description: "Flush iptables rules on VPS (useful when locked out) Requires services:manage and a full-access API key.",
1855
+ description: "Flush iptables rules on VPS (useful when locked out) Requires services:manage and a full-access API key. It clears the rules inside the server only; the public firewall that VPSnet applies to the server's public interface on its node is separate and is not changed by this.",
1764
1856
  inputSchema: {
1765
1857
  orderNo: serviceOrderNoSchema,
1766
1858
  },
@@ -1816,7 +1908,7 @@ export function createVpsnetServer(options = {}) {
1816
1908
  return { content: [{ type: "text", text: formatJson(data) }] };
1817
1909
  });
1818
1910
  server.registerTool("deploy_ssh_key", {
1819
- description: "Deploy an SSH key to VPS. Returns a noty UUID; follow it with wait_for_event. ASYNC — wait 15-30 seconds after deploying before attempting SSH. Use list_ssh_keys to get available key IDs. To add your own key first: read ~/.ssh/id_rsa.pub from local machine, then create_ssh_key, then deploy it here. Remote OAuth requires vm:exec; direct API-key use requires services:manage and a full-access key.",
1911
+ description: "Deploy an SSH key to VPS. Returns a noty UUID; follow it with wait_for_event until it reports completed before attempting SSH. Use list_ssh_keys to get available key IDs. To add your own key first: read ~/.ssh/id_rsa.pub from local machine, then create_ssh_key, then deploy it here. Remote OAuth requires vm:exec; direct API-key use requires services:manage and a full-access key.",
1820
1912
  inputSchema: {
1821
1913
  orderNo: serviceOrderNoSchema,
1822
1914
  ssh_key: positiveIdSchema.describe("SSH key ID from list_ssh_keys"),
@@ -1825,6 +1917,38 @@ export function createVpsnetServer(options = {}) {
1825
1917
  const { data } = await apiRequest("POST", svc(orderNo, "change-ssh-key"), { ssh_key });
1826
1918
  return { content: [{ type: "text", text: formatJson(data) }] };
1827
1919
  });
1920
+ server.registerTool("list_service_ssh_keys", {
1921
+ description: "List which account SSH keys are in root's authorized_keys on one owned Cloud VPS or Firecracker VPS, as the server last reported: per key deployed (true, false, or null when the server was never read) and a removal running or ended in the last 15 minutes; inventory carries source, observedAt, refreshing (a Firecracker VPS answers asynchronously), readFailure, passwordLogin and the count of other keys. refresh=true asks the server again. Container VPS and dedicated servers answer supported false with unsupportedReason. Free. Requires services:read when called with an API key.",
1922
+ inputSchema: {
1923
+ orderNo: serviceOrderNoSchema,
1924
+ refresh: z.boolean().optional().describe("Ask the server again even when the last read is fresh"),
1925
+ },
1926
+ annotations: {
1927
+ title: "List SSH keys on a server",
1928
+ readOnlyHint: true,
1929
+ destructiveHint: false,
1930
+ idempotentHint: true,
1931
+ },
1932
+ }, async ({ orderNo, refresh }) => {
1933
+ const { data } = await apiRequest("GET", svc(orderNo, "ssh-keys") + (refresh === true ? "?refresh=1" : ""));
1934
+ return { content: [{ type: "text", text: formatJson(data) }] };
1935
+ });
1936
+ server.registerTool("remove_service_ssh_key", {
1937
+ description: "Remove one account SSH key (ssh_key from list_ssh_keys) from root's authorized_keys on one owned running Cloud VPS or Firecracker VPS; every line with that key goes, nothing else changes, and the key stays in the account. Free. Confirm with the user first. A key that is not on the server answers state absent. Refused with 409 sshKeyRemoveLastKey when it is the server's last permanent key and root cannot log in with a password: add another key first. Cloud VPS answers at once (removed or absent); Firecracker VPS answers 202 pending and list_service_ssh_keys shows the result in the key's removal. One removal at a time per server, at most 10 per minute; 423 while the server's two-factor lock is on. Requires services:manage and a full-access API key.",
1938
+ inputSchema: {
1939
+ orderNo: serviceOrderNoSchema,
1940
+ ssh_key: positiveIdSchema.describe("SSH key ID from list_ssh_keys"),
1941
+ },
1942
+ annotations: {
1943
+ title: "Remove an SSH key from a server",
1944
+ readOnlyHint: false,
1945
+ destructiveHint: true,
1946
+ idempotentHint: true,
1947
+ },
1948
+ }, async ({ orderNo, ssh_key }) => {
1949
+ const { data } = await apiRequest("DELETE", svc(orderNo, `ssh-keys/${ssh_key}`));
1950
+ return { content: [{ type: "text", text: formatJson(data) }] };
1951
+ });
1828
1952
  // --- OS Reinstall ---
1829
1953
  server.registerTool("get_os_options", {
1830
1954
  description: "Get available OS templates for reinstall Requires services:read when called with an API key.",
@@ -1836,18 +1960,18 @@ export function createVpsnetServer(options = {}) {
1836
1960
  return { content: [{ type: "text", text: formatJson(data) }] };
1837
1961
  });
1838
1962
  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.",
1963
+ description: reinstallDescription(false),
1840
1964
  inputSchema: {
1841
1965
  orderNo: serviceOrderNoSchema,
1842
1966
  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."),
1967
+ 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
1968
  osVersion: z
1845
1969
  .number()
1846
1970
  .describe("OS version ID from get_os_options"),
1847
1971
  rootPassword: z
1848
1972
  .string()
1849
1973
  .optional()
1850
- .describe("New root password (auto-generated if omitted). 6-40 chars, alphanumeric, must contain uppercase + lowercase + digit"),
1974
+ .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
1975
  confirmSnapshotDelete: z
1852
1976
  .boolean()
1853
1977
  .optional()
@@ -1859,6 +1983,8 @@ export function createVpsnetServer(options = {}) {
1859
1983
  idempotentHint: false,
1860
1984
  },
1861
1985
  }, async ({ orderNo, osVersion, rootPassword, confirmSnapshotDelete, confirmReinstall, sshKey }) => {
1986
+ if (rootPassword !== undefined && sshKey !== undefined)
1987
+ throw new Error("Choose an account sshKey or rootPassword, not both.");
1862
1988
  const options = await apiRequest("GET", svc(orderNo, "change-os"));
1863
1989
  const capability = options.data;
1864
1990
  if (options.status >= 400 || capability?.success !== true) {
@@ -1868,15 +1994,13 @@ export function createVpsnetServer(options = {}) {
1868
1994
  if (capability.dedicated === true) {
1869
1995
  if (confirmReinstall !== true)
1870
1996
  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
1997
  body.confirmReinstall = true;
1874
- if (sshKey !== undefined)
1875
- body.sshKey = sshKey;
1876
1998
  }
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.");
1999
+ else if (confirmReinstall !== undefined) {
2000
+ throw new Error("confirmReinstall is a dedicated-only reinstall field; inspect get_os_options for this service.");
1879
2001
  }
2002
+ if (sshKey !== undefined)
2003
+ body.sshKey = sshKey;
1880
2004
  if (rootPassword)
1881
2005
  body.rootPassword = rootPassword;
1882
2006
  if (confirmSnapshotDelete === true)
@@ -2269,7 +2393,7 @@ export function createVpsnetServer(options = {}) {
2269
2393
  });
2270
2394
  // --- API Keys ---
2271
2395
  server.registerTool("list_api_keys", {
2272
- description: "Show the API key this request authenticated with: scope (read/full/ai), granular scopes, paid_scopes, paid_operations_enabled, daily_spend_limit_eur, monthly_spend_limit_eur, allowed IPs, expiry and rate_limit (requests per minute), plus permission_options listing every scope the panel can grant. The backend scopes this endpoint to the calling key itself (apiKeyScopedToSelf), so other keys on the account are never listed here. By design an API key can never create, change or revoke API keys (403 apiKeyForbidden, reason api_keys_cannot_manage_api_keys): that is done in the VPSnet panel while signed in. Read-only.",
2396
+ description: "Show the API key this request authenticated with: scope (read/full/ai), granular scopes, paid_scopes, paid_operations_enabled, daily_spend_limit_eur, monthly_spend_limit_eur, allowed IPs, expiry and rate_limit (a stored value the limiter does not read: every key may make 300 requests per 60 seconds by default, fewer on some operations), plus permission_options listing every scope the panel can grant. The backend scopes this endpoint to the calling key itself (apiKeyScopedToSelf), so other keys on the account are never listed here. By design an API key can never create, change or revoke API keys (403 apiKeyForbidden, reason api_keys_cannot_manage_api_keys): that is done in the VPSnet panel while signed in. Read-only.",
2273
2397
  inputSchema: {},
2274
2398
  }, async () => {
2275
2399
  const { data } = await apiRequest("GET", "/account/api-keys");
@@ -2285,7 +2409,7 @@ export function createVpsnetServer(options = {}) {
2285
2409
  return { content: [{ type: "text", text: formatJson(data) }] };
2286
2410
  });
2287
2411
  server.registerTool("get_api_key_activity", {
2288
- description: "Get the recorded request activity for this API key: recent matched route patterns and statuses, resolved and separately labelled caller-claimed source addresses, endpoint/source totals, and retained daily history. API-key callers can inspect only the same key that authenticated the MCP connection. Key values, headers, query strings, path values, and request bodies are never returned. To protect the control plane during abusive bursts, audit writes are capped per key per minute and retained detail has a separate per-key row ceiling; totals describe recorded rows and are not a billing ledger.",
2412
+ description: "Get the recorded request activity for this API key: recent matched route patterns and statuses, resolved and separately labelled caller-claimed source addresses, endpoint/source totals, and retained daily history. API-key callers can inspect only the same key that authenticated the MCP connection, and this server needs a management key: a management key has no inference usage of its own and an inference key is refused on every account route, so read inference usage in the VPSnet panel. Key values, headers, query strings, path values, and request bodies are never returned. To protect the control plane during abusive bursts, audit writes are capped per key per minute and retained detail has a separate per-key row ceiling; totals describe recorded rows and are not a billing ledger.",
2289
2413
  inputSchema: {
2290
2414
  id: positiveIdSchema.describe("API key ID from list_api_keys (the calling key's own ID)"),
2291
2415
  limit: z.number().int().min(1).max(200).optional().describe("Recent request rows to return (default 50, maximum 200)"),
@@ -2296,7 +2420,7 @@ export function createVpsnetServer(options = {}) {
2296
2420
  return { content: [{ type: "text", text: formatJson(data) }] };
2297
2421
  });
2298
2422
  server.registerTool("get_api_key_inference_usage", {
2299
- description: "Get exact paid VPSnet AI usage for this inference API key: committed and currently reserved spend for the UTC day and month, request and token totals by public VPSnet model profile, configured spend limits, remaining spend, and notification preferences. API-key callers can inspect only the same key that authenticated the MCP connection. Internal provider model names are never returned. This is read-only and does not change limits, notifications, or billing.",
2423
+ description: "Get exact paid VPSnet AI usage for this inference API key: committed and currently reserved spend for the UTC day and month, request and token totals by public VPSnet model profile, configured spend limits, remaining spend, and notification preferences. API-key callers can inspect only the same key that authenticated the MCP connection, and this server needs a management key: a management key has no inference usage of its own and an inference key is refused on every account route, so read inference usage in the VPSnet panel. Internal provider model names are never returned. This is read-only and does not change limits, notifications, or billing.",
2300
2424
  inputSchema: {
2301
2425
  id: positiveIdSchema.describe("Inference API key ID from list_api_keys (the calling key's own ID)"),
2302
2426
  },
@@ -2307,7 +2431,7 @@ export function createVpsnetServer(options = {}) {
2307
2431
  // --- TLS certificates ---
2308
2432
  const certificatePaymentSchema = z
2309
2433
  .object({
2310
- payment: z.number().int().positive().describe("Payment method ID; use 1 for account balance"),
2434
+ payment: unboundedInteger().positive().describe("Payment method ID; use 1 for account balance"),
2311
2435
  successUrl: z.string().max(2048).describe("Approved VPSnet redirect URL, or an empty string"),
2312
2436
  cancelUrl: z.string().max(2048).describe("Approved VPSnet redirect URL, or an empty string"),
2313
2437
  })
@@ -3098,7 +3222,7 @@ export function createVpsnetServer(options = {}) {
3098
3222
  return { content: [{ type: "text", text: formatJson(data) }] };
3099
3223
  });
3100
3224
  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.",
3225
+ 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
3226
  inputSchema: {
3103
3227
  domain_id: positiveIdSchema.describe("Owned domain ID from list_domains"),
3104
3228
  },
@@ -3114,7 +3238,7 @@ export function createVpsnetServer(options = {}) {
3114
3238
  return { content: [{ type: "text", text: formatJson(data) }] };
3115
3239
  });
3116
3240
  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.",
3241
+ 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
3242
  inputSchema: {
3119
3243
  domain_id: positiveIdSchema.describe("Owned domain ID from list_domains"),
3120
3244
  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 +4326,7 @@ export function createVpsnetServer(options = {}) {
4202
4326
  return { content: [{ type: "text", text: formatJson(data) }] };
4203
4327
  });
4204
4328
  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.",
4329
+ description: createDnsZoneDescription(),
4206
4330
  inputSchema: {
4207
4331
  zone: z.string().describe("Zone name, e.g. example.com"),
4208
4332
  kind: dnsZoneKindSchema
@@ -4227,7 +4351,7 @@ export function createVpsnetServer(options = {}) {
4227
4351
  },
4228
4352
  annotations: {
4229
4353
  readOnlyHint: false,
4230
- destructiveHint: false,
4354
+ destructiveHint: true,
4231
4355
  openWorldHint: true,
4232
4356
  },
4233
4357
  }, async ({ zone, kind, secondary_masters, secondary_tsig_key_name, secondary_tsig_algorithm, secondary_tsig_secret }) => {
@@ -4312,7 +4436,7 @@ export function createVpsnetServer(options = {}) {
4312
4436
  template: dnsTemplateIdSchema
4313
4437
  .describe("Template ID returned by list_dns_templates, e.g. web_service, null_mx, google_workspace, microsoft_365, mail_security, caa_letsencrypt"),
4314
4438
  parameters: z
4315
- .record(z.union([z.string(), z.number(), z.boolean()]))
4439
+ .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
4316
4440
  .optional()
4317
4441
  .describe("Template parameters, e.g. { ipv4, ipv6, www, mx_target, ttl } depending on the template"),
4318
4442
  preview: z
@@ -4334,13 +4458,19 @@ export function createVpsnetServer(options = {}) {
4334
4458
  return { content: [{ type: "text", text: formatJson(data) }] };
4335
4459
  });
4336
4460
  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.",
4461
+ 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
4462
  inputSchema: {
4339
4463
  zone_id: positiveIdSchema.describe("DNS zone ID"),
4464
+ confirm_outage: z
4465
+ .boolean()
4466
+ .optional()
4467
+ .default(false)
4468
+ .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
4469
  },
4341
4470
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
4342
- }, async ({ zone_id }) => {
4343
- const { data } = await apiRequest("DELETE", `/account/dns/zones/${encodeURIComponent(String(zone_id))}`);
4471
+ }, async ({ zone_id, confirm_outage }) => {
4472
+ const query = confirm_outage === true ? "?confirm_outage=true" : "";
4473
+ const { data } = await apiRequest("DELETE", `/account/dns/zones/${encodeURIComponent(String(zone_id))}${query}`);
4344
4474
  return { content: [{ type: "text", text: formatJson(data) }] };
4345
4475
  });
4346
4476
  server.registerTool("reapply_dns_zone", {