vpsnet-mcp 1.0.8 → 2.1.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 +489 -26
- package/build/api-configuration.js +59 -0
- package/build/api.js +179 -10
- package/build/application-contract.js +810 -0
- package/build/auth-failure.js +141 -0
- package/build/certificate-contract.js +1614 -0
- package/build/custom-project-contract.js +474 -0
- package/build/file-restore-contract.js +246 -0
- package/build/index.js +3617 -125
- package/build/service-rescue-contract.js +97 -0
- package/build/temp-vm-contract.js +284 -0
- package/package.json +21 -6
package/build/index.js
CHANGED
|
@@ -1,27 +1,72 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { isIP } from "node:net";
|
|
4
6
|
import { z } from "zod";
|
|
5
7
|
import { apiRequest, formatJson } from "./api.js";
|
|
6
|
-
|
|
8
|
+
import { applicationAccessConfigurationRequestBody, applicationAccessSchema, applicationSingleAccessSchema, applicationActionCancellationIsAdvertised, applicationActionCancellationRequestBody, applicationActionIdSchema, applicationActionSchema, applicationDataRestoreIdSchema, applicationDataRestorePointIdSchema, applicationDataRestoreQuoteRequestBody, applicationDataRestoreQuoteTokenSchema, applicationDataRestoreRequestBody, applicationExpectedVersionSchema, applicationInstallRequestBody, applicationLifecycleRequestBody, applicationLogMaxBytesSchema, applicationLogServiceSchema, applicationLogTailLinesSchema, applicationRevisionSchema, applicationResourceCpuPercentSchema, applicationResourceEmailEnabledSchema, applicationResourceMemoryMiBSchema, applicationResourceNetworkMiBPerMinuteSchema, applicationResourceRestartDeltaSchema, applicationResourceThresholdRequestBody, applicationUpdateCandidateMatches, safeApplicationInspectionPayload, safeApplicationDataRestorePayload, safeApplicationDataRestorePointsPayload, safeApplicationDataRestoreQuotePayload, safeApplicationMutationPayload, safeApplicationRegistryCredentialPayload, } from "./application-contract.js";
|
|
9
|
+
import { composeAdoptionIdSchema, composeProjectLabelSchema, customProjectComposeSchema, customProjectDefinitionRequestBody, customProjectEnvironmentSchema, customProjectIdSchema, customProjectNameSchema, customProjectRegistryCredentialIdsSchema, customProjectRevisionSchema, customProjectSecretNamesSchema, customProjectSecretsSchema, safeComposeAdoptionConfirmationPayload, safeComposeAdoptionPayload, safeContainerDiscoveryPayload, safeCustomProjectInstallPayload, safeCustomProjectPayload, safeCustomProjectReceiptPayload, safeCustomProjectValidationPayload, } from "./custom-project-contract.js";
|
|
10
|
+
import { certificateActionRequestBody, certificateActionRequestSchema, certificateAcmeSubscriptionActionRequestBody, certificateAcmeSubscriptionActionRequestSchema, certificateAcmeSubscriptionDomainInputShape, certificateAcmeSubscriptionInputShape, certificateOrderIdSchema, certificateOrderInputShape, certificateProductIdSchema, certificateSubscriptionIdSchema, freeCertificateActionRequestBody, freeCertificateActionRequestSchema, freeCertificateCreateInputShape, freeCertificateCreateRequestSchema, freeCertificatePreflightInputShape, freeCertificatePreflightRequestSchema, freeCertificateRequestIdSchema, freeCertificateStateSchema, safeCertificatePayload, } from "./certificate-contract.js";
|
|
11
|
+
import { fileBrowseDirectoryEntryIdSchema, fileBrowseFilterSchema, fileBrowseIdSchema, fileBrowseOffsetSchema, fileBrowsePointIdSchema, fileBrowseRequestBody, fileBrowseRequestRejection, readSearchAvailable, safeFileBrowsePayload, safeFileBrowsePointsPayload, } from "./file-restore-contract.js";
|
|
12
|
+
import { parseServiceRescueStatus, safeServiceRescuePayload, serviceRescueEnterRequestBody, serviceRescueImageIdSchema, } from "./service-rescue-contract.js";
|
|
13
|
+
import { safeTempVmPayload, tempVmIdempotencyKeySchema, tempVmIdSchema, tempVmOsIdSchema, tempVmProfileSchema, tempVmQuoteTokenSchema, tempVmRootPasswordSchema, tempVmSshPublicKeySchema, tempVmTtlSchema, } from "./temp-vm-contract.js";
|
|
14
|
+
// Accepted by GET /account/services/{orderNo}/graphs (GraphsController).
|
|
15
|
+
const GRAPH_METRICS = [
|
|
16
|
+
"cpu", "ram", "ssd", "storage", "swap", "lavg", "io", "inode", "net", "proc",
|
|
17
|
+
"fc_block", "fc_net_native", "fc_pressure",
|
|
18
|
+
];
|
|
19
|
+
const GRAPH_PERIODS = ["5m", "15m", "30m", "1h", "12h", "1d", "3d", "7d", "30d", "60d", "90d", "1y"];
|
|
20
|
+
// A numeric event id, or the lowercase/uppercase `noty` UUID an action returned.
|
|
21
|
+
const EVENT_ID_SCHEMA = z
|
|
22
|
+
.union([
|
|
23
|
+
z.number().int().positive(),
|
|
24
|
+
z.string().regex(/^(?:[0-9]{1,20}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/),
|
|
25
|
+
])
|
|
26
|
+
.describe("Numeric `event` id or `noty` UUID returned by the action");
|
|
27
|
+
const server = new McpServer({ name: "vpsnet", version: "2.1.0" }, {
|
|
7
28
|
instructions: [
|
|
8
|
-
"This MCP server controls VPS
|
|
9
|
-
"
|
|
29
|
+
"This MCP server controls VPSnet.com services, including VPS service management, DNS zones, domain registration, domain contacts, API keys, billing, and related paid actions.",
|
|
30
|
+
"Use the tool descriptions and API-key scopes to choose the correct surface; do not assume this server is limited to VPS-only operations.",
|
|
31
|
+
"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.",
|
|
32
|
+
"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'.",
|
|
33
|
+
"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.",
|
|
10
34
|
"",
|
|
11
35
|
"## Ordering a new VPS",
|
|
12
36
|
"Flow: get_order_plans → get_order_options(plan) → order_service.",
|
|
13
37
|
"Payment object format: { payment: <numeric_id>, successUrl: '', cancelUrl: '' }.",
|
|
14
38
|
"For balance payment use payment ID 1: { payment: 1, successUrl: '', cancelUrl: '' }.",
|
|
39
|
+
"Paid API-key order tools require paid_operations_enabled, the matching paid scope (vps:order/vds:order/ds:order/fc:order), configured daily/monthly spend caps, and an idempotencyKey; order_service performs quote → confirm with a server quoteToken.",
|
|
15
40
|
"Resources is an array of numeric resource value IDs from get_order_options, e.g. [901, 907].",
|
|
16
41
|
"rootPassword rules: 6-40 chars, alphanumeric only, MUST contain uppercase + lowercase + digit. Example: 'MyPass123'.",
|
|
17
42
|
"sshKey and rootPassword are mutually exclusive — provide one or the other (or neither for auto-generated password).",
|
|
18
43
|
"After placing an order, wait 10-30 seconds before attempting SSH — the VPS needs time to boot and start SSH daemon.",
|
|
19
44
|
"deploy_ssh_key is also async — wait 15-30 seconds after deploying a key before attempting SSH to any VPS (not just new ones).",
|
|
20
45
|
"",
|
|
21
|
-
"##
|
|
46
|
+
"## Deployment capabilities (unordered)",
|
|
47
|
+
"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.",
|
|
48
|
+
"For an application deployment, inspect the relevant service and catalog state when it helps the decision. A catalog entry is one available managed path, not an instruction to override a valid manual or custom deployment request.",
|
|
49
|
+
"Managed Applications run as Docker containers inside supported customer servers and are managed through the typed application tools.",
|
|
50
|
+
"Managed application reads require applications:read. Install and ordinary lifecycle changes require applications:manage plus an idempotencyKey and are not paid API-key operations. Selective data restore is paid and additionally requires the quote/confirm flow and applications:restore paid scope.",
|
|
51
|
+
"Application CPU, RAM, and disk figures are sizing recommendations. Do not reject an installation or filter an otherwise supported order plan because it is below those figures; product, OS, architecture, and runtime compatibility remain hard requirements.",
|
|
52
|
+
"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.",
|
|
53
|
+
"install_application and manage_application are asynchronous. A queued response is not proof that the application is healthy; poll get_application_installation and inspect get_application_events.",
|
|
54
|
+
"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; external_https records a customer-managed HTTPS address and does not configure or validate DNS, TLS, or the customer's reverse proxy.",
|
|
55
|
+
"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.",
|
|
56
|
+
"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.",
|
|
57
|
+
"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.",
|
|
58
|
+
"discover_service_containers returns bounded read-only Docker metadata from a supported Firecracker service. Treat managed and detected containers as separate states. Discovery never modifies containers. Compose adoption is a separate prepare → inspect → explicit confirm flow; only confirm_application_compose_adoption stops source containers, and only after the user approves the exact candidate and source-stop acknowledgement. The initial takeover is one-time, while the exact external-volume binding remains signed into later lifecycle actions. If managed startup fails, source containers resume only after the managed replacement is conclusively contained; an uncertain outcome fails closed for operator recovery.",
|
|
59
|
+
"To check the real, current state of an installed application, run get_application_health (observed container health, not the possibly-stale last-reported value) and get_application_logs (recent size-bounded logs) for troubleshooting. Both queue a short inspection, so they require an API key that permits write operations even though the underlying scope is applications:read.",
|
|
60
|
+
"Immutable application update is supported only when get_application_installation returns available_actions with type=update. Confirm the exact advertised upstream_version and blueprint_version, then pass both as update preconditions with a fresh client-global idempotencyKey. Reuse that key only for the exact same service and request. The backend selects and freezes the eligible published release; never accept or invent a target image, tag, or version.",
|
|
61
|
+
"cancel_application_action is available only for the exact current latest_action while the backend advertises cancellable=true. It is a pre-dispatch cancellation request and never stops or interrupts a running worker job. Re-read the installation and use a fresh idempotencyKey that was not used for the original action.",
|
|
62
|
+
"No separate application backup is created. list_application_restore_points freely exposes only eligible application-consistent nightly whole-VM points for the exact current Firecracker application revision; API keys need applications:manage, paid operations enabled, applications:restore paid scope, and full access because the response includes account balance. Select a point, call quote_application_data_restore to freeze the exact balance charge, disclose it to the user, then call restore_application_data with the same idempotency key and quote token only after explicit approval of both the charge and destructive data replacement. Paid API keys also require spend caps before quote/confirmation. Poll get_application_data_restore; needs_attention is not success and keeps mutations locked. Never ask for or invent PBS credentials, archive IDs, devices, or filesystem paths.",
|
|
63
|
+
"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.",
|
|
64
|
+
"Never repeat application variable values in summaries or approval text. Refer only to variable names, especially for passwords, tokens, and secrets.",
|
|
65
|
+
"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.",
|
|
66
|
+
"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.",
|
|
22
67
|
"",
|
|
23
|
-
"### When the user asks
|
|
24
|
-
"
|
|
68
|
+
"### When the user asks for a manual or custom deployment INSIDE a VPS:",
|
|
69
|
+
"Deploy YOUR OWN SSH key first, then connect directly via SSH.",
|
|
25
70
|
"After deploying your key, you can INSTANTLY access the VPS — no extra tools needed.",
|
|
26
71
|
"Most AI environments (Claude Code, Cline, Cursor, Codex) have a built-in terminal/bash tool.",
|
|
27
72
|
"IMPORTANT: Grant FULL permissions/access to the AI tool — restricted/sandbox modes may block npm installs or SSH connections.",
|
|
@@ -62,6 +107,7 @@ const server = new McpServer({ name: "vpsnet", version: "1.0.0" }, {
|
|
|
62
107
|
"Get IDs from get_plan_resources: each resource type has a 'values' array, pick one value's 'id' per type.",
|
|
63
108
|
"Use isDefault=1 values for plan defaults. Do NOT pass an empty array — it will fail silently.",
|
|
64
109
|
"IP resources are typically disabled (managed by backend). Admin resources are auto-managed — do not include them.",
|
|
110
|
+
"KVM/Cloud VPS and Firecracker disks cannot be shrunk. A lower plan may reduce CPU or RAM only when its effective disk remains unchanged or grows; get_plan_options/get_plan_resources and backend validation reject smaller target disks.",
|
|
65
111
|
"",
|
|
66
112
|
"## Backups",
|
|
67
113
|
"Creating a backup is a PAID operation. Flow: get_backup_status → create_backup.",
|
|
@@ -69,15 +115,209 @@ const server = new McpServer({ name: "vpsnet", version: "1.0.0" }, {
|
|
|
69
115
|
"create_backup requires: period (YYYY-MM-DD date from options), directories (e.g. '/'), and payment object.",
|
|
70
116
|
"",
|
|
71
117
|
"## Async operations",
|
|
72
|
-
"All service actions (start/stop/restart/OS reinstall) are async — they return a noty UUID
|
|
118
|
+
"All service actions (start/stop/restart/OS reinstall, hostname, password, SSH key deploy) are async — they return a numeric `event` id or a `noty` UUID; pass either to get_event or wait_for_event to follow the action to completed or error.",
|
|
73
119
|
"",
|
|
74
120
|
"## Renewal",
|
|
75
121
|
"Payment object for renewal is the same format: { payment: 1, successUrl: '', cancelUrl: '' } for balance.",
|
|
76
122
|
"Flow: get_period_options → renew_service(orderNo, period, payment).",
|
|
123
|
+
"",
|
|
124
|
+
"## DNS management",
|
|
125
|
+
"DNS tools require granular API key scopes: dns:read for reads, dns:write for writes, and dnssec:manage for DNSSEC state changes.",
|
|
126
|
+
"Zones can be native (records managed in VPSNet) or secondary (AXFR from public primary servers; records and DNSSEC stay read-only here). Secondary zones require TSIG; generate a Base64 secret with openssl rand -base64 32 and configure the same key on the primary.",
|
|
127
|
+
"Zone import/export uses BIND-style zone files for forward DNS desired-state records. Import skips/rejects system-managed reverse/PTR/SOA/DNSSEC wire/apex-NS records; PTR remains in the existing service rDNS flow.",
|
|
128
|
+
"PTR / reverse DNS is NOT managed through DNS zones here. Use get_rdns, change_rdns, or clear_rdns for service reverse DNS.",
|
|
129
|
+
"To point a domain/hostname at a VPSNet service, prefer list_service_dns_options → attach_service_dns_record (validates the IP belongs to the service and defaults A/AAAA to the service IP). Use upsert_dns_record only for arbitrary record content.",
|
|
130
|
+
"",
|
|
131
|
+
"## Snapshots, restore and Firecracker Functions",
|
|
132
|
+
"VPS product facts (unordered): 'firecracker' is VPS using Firecracker microVMs for Linux workloads; 'vds' is Cloud VPS (KVM) with High Availability, replicated Ceph NVMe storage, and Linux/Windows/BSD support; 'vps' is Container VPS using container virtualization; 'ds' is a dedicated single-tenant server. Match the user's requirements and returned plan capabilities; list position is not a recommendation. Snapshot tools: Cloud VPS uses list/create/rollback/delete_snapshot; Firecracker VPS uses the *_firecracker_snapshot tools (temporary: free window, then billed per GB while kept, auto-expire). Firecracker Functions is a separate usage-billed service with create/update/invoke/list tools, not part of ordering or managing a VPS, Cloud VPS, or Dedicated service.",
|
|
133
|
+
"Snapshot-first is a default habit ON SERVICES THAT SUPPORT SNAPSHOTS — only Cloud VPS (vds) and Firecracker VPS have snapshots; Container VPS (vps) and Dedicated (ds) do NOT. Where supported, take a snapshot before any risky, destructive, or automated change (reinstall, rollback, bulk edits, unattended scripts) — it's free for an initial window, so it's cheap insurance you can roll back to. DELETE the snapshot once the change succeeds and you no longer need it — after the free window it is billed per GB while kept (Cloud VPS snapshots do NOT auto-expire), so never leave snapshots lying around. For Container VPS and Dedicated (no snapshots), be extra careful with destructive actions since there is no rollback safety net.",
|
|
134
|
+
"Snapshot rollback is DESTRUCTIVE (disk state after the snapshot is lost) — always confirm with the user first.",
|
|
135
|
+
"Cloud VPS and Firecracker VPS have automatic daily off-node backups. Restoring is PAID: get_restore_status shows the price, list_restore_points shows points, request_restore charges the account balance immediately and overwrites the service disk — confirm point and price with the user first, then pass that disclosed get_restore_status total back as expected_total_charged together with both acknowledgements, so a price that moved is refused instead of charged. request_restore performs the server quote → confirm flow itself with one Idempotency-Key; API keys need services:read, full access, paid operations enabled, the services:restore paid scope, and spend caps.",
|
|
136
|
+
"Looking INSIDE a backup is free and completely separate from paying to restore. list_restore_file_points, browse_restore_files, and get_restore_file_browse only read a backup's directory listing: they never charge the account, never overwrite the disk, and never restore a file. Browsing is asynchronous — poll get_restore_file_browse until state is succeeded. The server selects pages of 200 or 1,000 entries: keep paging with offset=result.nextOffset while nextOffset is non-null, use result.pageSize rather than assuming 200 when moving backwards, and say so when you are showing one page of a larger directory. Branch on result.listingStatus rather than on truncated alone — truncated=true with nextOffset=null is a legitimate capped scan (listingStatus 'partial'): that listing is a bounded slice that cannot be paged further, so present it as a lower bound instead of retrying. Folder search (the filter argument) needs a node capability that older workers lack; check searchAvailable from list_restore_file_points first. If search is unavailable the tool returns an error rather than an unfiltered listing — never present unfiltered entries as search results. Restoring selected files back onto the server is a paid operation that is not exposed here; direct the user to the VPSnet panel for it.",
|
|
137
|
+
"Firecracker Functions run code in isolated microVMs and are usage-billed per invocation. create_function needs name, runtime_os_id and code; invoke_function with wait=true returns the result synchronously. Webhook-enabled functions get a public webhook URL for external triggers.",
|
|
138
|
+
"On-demand servers are a coming-soon Firecracker compute service with full root SSH access. Customer quote/create operations are disabled and return tempVmComingSoon before payment or allocation. Use get_temp_vm_options to inspect the launch state; read and delete operations remain available for discovery and cleanup of existing test sessions.",
|
|
139
|
+
"To select a non-default on-demand server OS, use the selected profile's plan_id with get_order_options and pass an enabled non-runtime Firecracker guest OS ID. Omit os_id when there is no specific OS requirement; never invent an ID or use a Functions runtime image.",
|
|
140
|
+
"Before update_function, call get_function. If integrity.unreadable_fields names code or environment, do not update until the user explicitly approves replacing every unavailable value from a trusted copy. Only then pass acknowledge_unreadable_replacement=true. The backend rejects an unacknowledged replacement; never set the flag for an ordinary update.",
|
|
141
|
+
"The DNS API rejects PTR, *.in-addr.arpa, *.ip6.arpa, LUA, SOA/DNSSEC wire records, apex NS, and apex DS.",
|
|
142
|
+
"Dynamic DNS updater tokens are narrow credentials for one hostname/pattern inside a verified customer-owned zone. purpose=ddns allows A/AAAA updates; purpose=acme allows TXT only under _acme-challenge for DNS-01. They only operate inside verified customer-owned zones and can be restricted to source IP/CIDR ranges with allow_from.",
|
|
143
|
+
"",
|
|
144
|
+
"## Domain registration",
|
|
145
|
+
"Domain tools cover supported TLDs, availability checks, contacts, VPSNet-priced register/transfer/renewal quotes, and paid confirmations.",
|
|
146
|
+
"Use list_domains to discover owned domain IDs before quote_domain_renew.",
|
|
147
|
+
"Flow: quote_domain_register/quote_domain_transfer/quote_domain_renew/quote_domain_restore → confirm_domain_register/confirm_domain_transfer/confirm_domain_renew/confirm_domain_restore with the returned quoteToken, the same idempotencyKey, and a payment object. The domain action is queued only after VPSNet confirms the payment.",
|
|
148
|
+
"Domain registration, transfer, renewal, restore, and assignment payments are non-refundable once confirmed. Verify spelling, period, contacts, nameservers, and auth code before calling a confirm tool.",
|
|
149
|
+
"Customers choose only the domain and action. VPSNet returns the final price before payment; renew and restore use the price attached to that existing domain. Domain read tools require domains:read; contact writes require domains:manage; paid domain calls require an idempotencyKey and, for API keys, paid_operations_enabled plus domains:order, domains:transfer, or domains:renew with spend caps.",
|
|
150
|
+
"set_domain_nameservers changes delegation for an owned domain asynchronously; nameservers are hostnames only. It does not create same-domain nameserver IP records, DNS records, or PTR/reverse DNS.",
|
|
151
|
+
"Same-domain nameserver IP records are separate host/IP records such as ns1.customer.com -> 203.0.113.10. The hostname must be below the owned domain, and addresses must be public routable IPs. These tools do not change DNS records or PTR/reverse DNS.",
|
|
152
|
+
"Domain parent-DS tools manage DNSSEC DS records in the parent zone for an owned domain. They are separate from DNS zone records and never manage PTR/reverse DNS.",
|
|
153
|
+
"Transfer-away auth/EPP-code reveal is intentionally portal-only with 2FA/PIN step-up. The MCP uses X-API-KEY only and must not request or expose transfer-away credentials.",
|
|
154
|
+
"Use get_domain_ordering_status before paid domain tests to see whether domain search and ordering are currently available.",
|
|
155
|
+
"",
|
|
156
|
+
"## TLS certificates and Automatic SSL",
|
|
157
|
+
"Free portable certificates are separate from paid portable certificates, paid Automatic SSL subscriptions, and application Managed HTTPS. Start with get_free_certificate_eligibility and preflight_free_certificate. Only create when preflight says issuable and the user explicitly approves the exact names, CA, validation, delivery, and key-custody mode.",
|
|
158
|
+
"For API/AI-managed deployment outside VPSnet, use customer_csr: generate and keep the private key on the destination, send only its public PKCS#10 CSR, then retrieve the public leaf and chain with download_free_certificate. In managed mode VPSnet stores an encrypted key for unattended early renewal, but that key is portal-only behind two-factor verification and is never available to MCP or API keys. Never imply that a public-only download is a deployable key pair.",
|
|
159
|
+
"Free-certificate issuance is asynchronous and quota-controlled. Poll get_free_certificate; use get_free_certificate_instruction for external-DNS CNAME delegation and download_free_certificate only after download_available=true. A queued request is not proof of issuance. Managed-key renewals are scheduled early with bounded jitter; customer_csr renewals require a new CSR. Read state before manage_free_certificate, explicitly approve revoke/cancel/renew, and after any ambiguous response re-read state before deciding whether another action is needed.",
|
|
160
|
+
"Paid TLS certificates are portable account products, not managed-application-only features. They can be installed on customer Nginx, Apache, lighttpd, OpenLiteSpeed, HAProxy, Caddy, mail, API, load-balancer, or other TLS endpoints.",
|
|
161
|
+
"Managed HTTPS for VPSnet applications, Automatic SSL subscriptions for a customer ACME client, and portable certificate files are three separate choices. Do not place a paid order merely because an application already has managed platform HTTPS.",
|
|
162
|
+
"Automatic SSL flow: list_certificate_catalog → quote_automatic_ssl_subscription → order_automatic_ssl_subscription. It works with compatible ACME clients on VPSnet or another provider. A base domain includes its www alias; apex and wildcard names are separate subscribed identifiers and may be ordered together. Choose auto_renew explicitly; every next term is still quoted and prepaid from balance during the final 30 days.",
|
|
163
|
+
"After payment, poll get_automatic_ssl_subscription until state=active and credentials_available=true. The private ACME server URL and EAB credentials are intentionally portal-only behind two-factor verification; they are not MCP or API-key tools and must never enter model context.",
|
|
164
|
+
"Add a DNS name with quote_automatic_ssl_domain → order_automatic_ssl_domain using the exact quote token and unchanged idempotency key. The quote prevents duplicate coverage from base/www and wildcard/base relationships.",
|
|
165
|
+
"When renewal.available=true, use quote_automatic_ssl_renewal → order_automatic_ssl_renewal. Renewal is balance-only and must be explicitly approved; payment_processing or manual_review is not success.",
|
|
166
|
+
"Use list_automatic_ssl_actions before manage_automatic_ssl_subscription. Cancellation, removal, and same-type correction are durable and idempotent. Reuse a key only for the exact same request. For reconciliation_required or outcome_ambiguous, call refresh_automatic_ssl_subscription and read state instead of creating a new mutation.",
|
|
167
|
+
"Flow: list_certificate_catalog → quote_certificate → order_certificate. The quote returns the final customer price in EUR including VAT, a short-lived quote token, and the exact public-key type derived from the CSR. Disclose the exact total and obtain explicit approval before ordering.",
|
|
168
|
+
"The customer creates and retains the private key. Certificate tools accept only a signed public PKCS#10 CSR. Never ask for, accept, store, repeat, or place a private key in a tool argument or model context.",
|
|
169
|
+
"Use the same client-global idempotencyKey and exact unchanged order fields for quote_certificate and order_certificate. API keys require certificates:read for reads, certificates:manage for management, and paid operations plus certificates:order and spend caps for quote/order.",
|
|
170
|
+
"Certificate renewal is a separately paid successor order. Set renewal_of only for the exact eligible active certificate returned by list_certificates during its renewal window; never invent an earlier certificate identity.",
|
|
171
|
+
"A queued or paid order is not proof of issuance. Poll get_certificate and get_certificate_validation. attention_required is not success. download_certificate returns only the public leaf and chain and explicitly never returns a private key.",
|
|
172
|
+
"Management actions are durable and idempotent. Reuse an idempotency key only for an exact retry of the same action. If an outcome is reconciliation_required or outcome_ambiguous, use refresh_certificate and read state; do not submit the mutation again with a new key.",
|
|
77
173
|
].join("\n"),
|
|
78
174
|
});
|
|
79
175
|
// Helper to build service settings path
|
|
80
176
|
const svc = (orderNo, action) => `/account/services/${orderNo}/${action}`;
|
|
177
|
+
const idempotencyKeySchema = z
|
|
178
|
+
.string()
|
|
179
|
+
.min(8)
|
|
180
|
+
.max(190)
|
|
181
|
+
.regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{7,189}$/);
|
|
182
|
+
const paidIdempotencyKeySchema = idempotencyKeySchema
|
|
183
|
+
.min(16)
|
|
184
|
+
.describe("Client-global unique key of at least 16 characters for one exact paid operation.");
|
|
185
|
+
const serviceOrderNoSchema = z
|
|
186
|
+
.string()
|
|
187
|
+
.min(1)
|
|
188
|
+
.max(64)
|
|
189
|
+
.describe("Tenant-owned service order number, e.g. VP88318");
|
|
190
|
+
const serviceHostnameSchema = z
|
|
191
|
+
.string()
|
|
192
|
+
.min(3)
|
|
193
|
+
.max(154)
|
|
194
|
+
.refine((hostname) => {
|
|
195
|
+
const labels = hostname.split(".");
|
|
196
|
+
return (labels.length <= 5 &&
|
|
197
|
+
labels.every((label) => /^(?!-)[A-Za-z0-9-]{1,30}(?<!-)$/u.test(label)));
|
|
198
|
+
}, "Hostname must contain at most five 1-30 character DNS labels")
|
|
199
|
+
.describe("Customer hostname: 3-154 ASCII characters, at most five labels, each 1-30 alphanumeric/hyphen characters without a leading or trailing hyphen");
|
|
200
|
+
const serviceIpSchema = z
|
|
201
|
+
.string()
|
|
202
|
+
.refine((value) => isIP(value) !== 0, "A valid IPv4 or IPv6 address is required")
|
|
203
|
+
.describe("Assigned IPv4 or IPv6 address returned by get_rdns");
|
|
204
|
+
const servicePtrSchema = z
|
|
205
|
+
.string()
|
|
206
|
+
.refine((value) => {
|
|
207
|
+
const canonical = value.trim().replace(/\.+$/u, "").toLowerCase();
|
|
208
|
+
return (canonical.length >= 3 &&
|
|
209
|
+
canonical.length <= 253 &&
|
|
210
|
+
canonical.includes("vpsnet") === false &&
|
|
211
|
+
canonical.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)));
|
|
212
|
+
}, "PTR must be a 3-253 character hostname with 1-63 character labels and no reserved VPSnet name")
|
|
213
|
+
.describe("PTR hostname. Canonical length 3-253, labels 1-63 ASCII alphanumeric/hyphen characters, no leading/trailing hyphen; an optional trailing dot is removed");
|
|
214
|
+
const applicationOrderNoSchema = z
|
|
215
|
+
.string()
|
|
216
|
+
.regex(/^[A-Z]{2}[0-9]+$/)
|
|
217
|
+
.describe("Tenant-owned service order number, e.g. VP88146");
|
|
218
|
+
const applicationInstallationIdSchema = z
|
|
219
|
+
.string()
|
|
220
|
+
.uuid()
|
|
221
|
+
.describe("Managed application installation UUID");
|
|
222
|
+
const applicationSlugSchema = z
|
|
223
|
+
.string()
|
|
224
|
+
.regex(/^[a-z0-9][a-z0-9-]{0,95}$/)
|
|
225
|
+
.describe("Published application slug from list_application_catalog");
|
|
226
|
+
const applicationVariablesSchema = z
|
|
227
|
+
.record(z.string().regex(/^[A-Z][A-Z0-9_]{0,127}$/), z.union([z.string().max(4096), z.number().finite(), z.boolean(), z.null()]))
|
|
228
|
+
.refine((variables) => Object.keys(variables).length <= 64, {
|
|
229
|
+
message: "At most 64 application variables may be submitted",
|
|
230
|
+
})
|
|
231
|
+
.optional()
|
|
232
|
+
.describe("Configuration values keyed by the public variable names in the catalog. Values are submitted to VPSnet but never returned by this tool.");
|
|
233
|
+
function applicationPath(orderNo, suffix) {
|
|
234
|
+
return `/account/services/${encodeURIComponent(orderNo)}/applications/${suffix}`;
|
|
235
|
+
}
|
|
236
|
+
function safeApplicationMutationResult(status, data, orderNo) {
|
|
237
|
+
return formatJson(safeApplicationMutationPayload(status, data, `/management/service/${encodeURIComponent(orderNo)}/applications`));
|
|
238
|
+
}
|
|
239
|
+
function safeApplicationInspectionResult(status, data, kind) {
|
|
240
|
+
return formatJson(safeApplicationInspectionPayload(status, data, kind));
|
|
241
|
+
}
|
|
242
|
+
const nameserverHostnameSchema = z
|
|
243
|
+
.string()
|
|
244
|
+
.trim()
|
|
245
|
+
.transform((value) => value.toLowerCase().replace(/\.$/, ""))
|
|
246
|
+
.refine((value) => !/^(?:\d{1,3}\.){3}\d{1,3}$/.test(value) && !value.includes(":"), "Nameserver must be a hostname, not an IP address")
|
|
247
|
+
.refine((value) => value.length <= 253 && value.includes("."), "Nameserver must be a fully qualified hostname")
|
|
248
|
+
.refine((value) => value
|
|
249
|
+
.split(".")
|
|
250
|
+
.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)), "Nameserver hostname is invalid");
|
|
251
|
+
const whoisPrivacySchema = z
|
|
252
|
+
.boolean()
|
|
253
|
+
.optional()
|
|
254
|
+
.describe("Hide public WHOIS contact details where supported. Defaults to the TLD setting.");
|
|
255
|
+
const isPublicIp = (value) => {
|
|
256
|
+
const version = isIP(value);
|
|
257
|
+
if (version === 4) {
|
|
258
|
+
const octets = value.split(".").map((part) => Number.parseInt(part, 10));
|
|
259
|
+
if (octets.length !== 4 || octets.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
const [a, b, c] = octets;
|
|
263
|
+
return !(a === 0 ||
|
|
264
|
+
a === 10 ||
|
|
265
|
+
a === 127 ||
|
|
266
|
+
a >= 224 ||
|
|
267
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
268
|
+
(a === 169 && b === 254) ||
|
|
269
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
270
|
+
(a === 192 && b === 0 && c === 0) ||
|
|
271
|
+
(a === 192 && b === 168) ||
|
|
272
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
273
|
+
(a === 192 && b === 0 && c === 2) ||
|
|
274
|
+
(a === 198 && b === 51 && c === 100) ||
|
|
275
|
+
(a === 203 && b === 0 && c === 113));
|
|
276
|
+
}
|
|
277
|
+
if (version === 6) {
|
|
278
|
+
const lower = value.toLowerCase();
|
|
279
|
+
return !(lower === "::" ||
|
|
280
|
+
lower === "::1" ||
|
|
281
|
+
lower.startsWith("fc") ||
|
|
282
|
+
lower.startsWith("fd") ||
|
|
283
|
+
lower.startsWith("fe80") ||
|
|
284
|
+
lower.startsWith("ff") ||
|
|
285
|
+
lower.startsWith("2001:db8"));
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
};
|
|
289
|
+
const glueHostnameSchema = nameserverHostnameSchema.describe("Nameserver hostname below the owned domain, e.g. ns1.example.com. IP addresses are not accepted as hostnames.");
|
|
290
|
+
const gluePublicIpSchema = z
|
|
291
|
+
.string()
|
|
292
|
+
.trim()
|
|
293
|
+
.refine((value) => isIP(value) !== 0, "Nameserver address must be an IPv4 or IPv6 address")
|
|
294
|
+
.refine(isPublicIp, "Nameserver address must be public and routable");
|
|
295
|
+
const dnsRecordTypeSchema = z.enum([
|
|
296
|
+
"A",
|
|
297
|
+
"AAAA",
|
|
298
|
+
"ALIAS",
|
|
299
|
+
"CAA",
|
|
300
|
+
"CNAME",
|
|
301
|
+
"DNAME",
|
|
302
|
+
"DS",
|
|
303
|
+
"HINFO",
|
|
304
|
+
"HTTPS",
|
|
305
|
+
"IPSECKEY",
|
|
306
|
+
"LOC",
|
|
307
|
+
"MX",
|
|
308
|
+
"NAPTR",
|
|
309
|
+
"NS",
|
|
310
|
+
"OPENPGPKEY",
|
|
311
|
+
"RP",
|
|
312
|
+
"SMIMEA",
|
|
313
|
+
"SRV",
|
|
314
|
+
"SSHFP",
|
|
315
|
+
"SVCB",
|
|
316
|
+
"TLSA",
|
|
317
|
+
"TXT",
|
|
318
|
+
"URI",
|
|
319
|
+
]);
|
|
320
|
+
const dnsZoneKindSchema = z.enum(["native", "secondary"]);
|
|
81
321
|
// --- Account ---
|
|
82
322
|
server.registerTool("get_account", {
|
|
83
323
|
description: "Get account info: user ID, email, balance, VAT rate",
|
|
@@ -95,14 +335,14 @@ server.registerTool("get_profile", {
|
|
|
95
335
|
});
|
|
96
336
|
// --- Services ---
|
|
97
337
|
server.registerTool("list_services", {
|
|
98
|
-
description: "List all active VPS services with state, plan, IPs, and expiry",
|
|
338
|
+
description: "List all active VPS services with state, plan, IPs, and expiry Requires services:read when called with an API key.",
|
|
99
339
|
inputSchema: {},
|
|
100
340
|
}, async () => {
|
|
101
341
|
const { data } = await apiRequest("GET", "/account/services");
|
|
102
342
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
103
343
|
});
|
|
104
344
|
server.registerTool("get_service", {
|
|
105
|
-
description: "Get detailed info for a service by order number",
|
|
345
|
+
description: "Get detailed info for a service by order number. Resource-usage rows include available; false means the numeric zero is a compatibility placeholder, not a measurement. Requires services:read when called with an API key.",
|
|
106
346
|
inputSchema: {
|
|
107
347
|
orderNo: z.string().describe("Order number, e.g. VP57068"),
|
|
108
348
|
},
|
|
@@ -111,16 +351,62 @@ server.registerTool("get_service", {
|
|
|
111
351
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
112
352
|
});
|
|
113
353
|
server.registerTool("get_service_graphs", {
|
|
114
|
-
description: "Get performance
|
|
354
|
+
description: "Get one performance graph series for a service: pick a metric and a period. fc_block, fc_net_native and fc_pressure exist only for Firecracker VPS. fields narrows io/net/fc_* metrics to some series. A wrong metric, period or field answers 422 with invalidParameter and allowedValues. Requires services:read when called with an API key.",
|
|
115
355
|
inputSchema: {
|
|
116
356
|
orderNo: z.string().describe("Order number"),
|
|
357
|
+
metric: z
|
|
358
|
+
.enum(GRAPH_METRICS)
|
|
359
|
+
.describe("Metric: cpu, ram, ssd (disk use; storage is the same), swap, lavg (load average), io, inode, net, proc, or a Firecracker-only fc_* metric"),
|
|
360
|
+
period: z.enum(GRAPH_PERIODS).describe("Time window ending now"),
|
|
361
|
+
fields: z
|
|
362
|
+
.array(z.string().regex(/^[a-z_]+$/))
|
|
363
|
+
.min(1)
|
|
364
|
+
.optional()
|
|
365
|
+
.describe("Only these series. io: ior, iow, iops. net: rx, tx, rxp, txp. fc_block: read_bytes, write_bytes, read_ops, write_ops, queue_events, throttle_events. fc_net_native: rx_bytes, tx_bytes, rx_packets, tx_packets, queue_events. fc_pressure: major_faults, minor_faults, swap_in, swap_out, oom_kills, alloc_stalls, async_scan, direct_scan, async_reclaim, direct_reclaim."),
|
|
117
366
|
},
|
|
118
|
-
}, async ({ orderNo }) => {
|
|
119
|
-
const
|
|
367
|
+
}, async ({ orderNo, metric, period, fields }) => {
|
|
368
|
+
const query = new URLSearchParams({ m: metric, p: period });
|
|
369
|
+
if (fields !== undefined) {
|
|
370
|
+
query.set("f", fields.join(","));
|
|
371
|
+
}
|
|
372
|
+
const { data } = await apiRequest("GET", `/account/services/${orderNo}/graphs?${query.toString()}`);
|
|
373
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
374
|
+
});
|
|
375
|
+
server.registerTool("get_event", {
|
|
376
|
+
description: "Read the state of an asynchronous action: created, proccessing (sic), completed or error, with its message and payload. Accepts the numeric event id (start/stop/restart/console/suspend/resume return `event`) or the `noty` UUID (hostname, root password, SSH key deploy, OS reinstall, IPv6, extra settings and plan changes return `noty`). Service events need services:read; account events need account:read. Read-only.",
|
|
377
|
+
inputSchema: {
|
|
378
|
+
id: EVENT_ID_SCHEMA,
|
|
379
|
+
},
|
|
380
|
+
}, async ({ id }) => {
|
|
381
|
+
const { data } = await apiRequest("GET", `/account/events/${encodeURIComponent(String(id))}`);
|
|
120
382
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
121
383
|
});
|
|
384
|
+
server.registerTool("wait_for_event", {
|
|
385
|
+
description: "Poll an asynchronous action until it is completed or error, or until timeout_seconds pass (default 120, max 600), every 3 seconds. Same ids as get_event. Returns the last state and waited_seconds; timed_out=true means the action is still running, not that it failed. Read-only.",
|
|
386
|
+
inputSchema: {
|
|
387
|
+
id: EVENT_ID_SCHEMA,
|
|
388
|
+
timeout_seconds: z.number().int().min(5).max(600).optional().describe("How long to wait (default 120)"),
|
|
389
|
+
},
|
|
390
|
+
}, async ({ id, timeout_seconds }) => {
|
|
391
|
+
const limitMs = (timeout_seconds ?? 120) * 1000;
|
|
392
|
+
const started = Date.now();
|
|
393
|
+
let last = null;
|
|
394
|
+
for (;;) {
|
|
395
|
+
const { status, data } = await apiRequest("GET", `/account/events/${encodeURIComponent(String(id))}`);
|
|
396
|
+
last = data;
|
|
397
|
+
const state = data?.state;
|
|
398
|
+
const waited = Math.round((Date.now() - started) / 1000);
|
|
399
|
+
if (status < 200 || status >= 300 || state === "completed" || state === "error") {
|
|
400
|
+
return { content: [{ type: "text", text: formatJson({ ...data, waited_seconds: waited, timed_out: false }) }] };
|
|
401
|
+
}
|
|
402
|
+
if (Date.now() - started + 3000 > limitMs) {
|
|
403
|
+
return { content: [{ type: "text", text: formatJson({ ...last, waited_seconds: waited, timed_out: true }) }] };
|
|
404
|
+
}
|
|
405
|
+
await inspectionSleep(3000);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
122
408
|
server.registerTool("get_service_history", {
|
|
123
|
-
description: "Get action history for a service",
|
|
409
|
+
description: "Get action history for a service Requires services:read when called with an API key.",
|
|
124
410
|
inputSchema: {
|
|
125
411
|
orderNo: z.string().describe("Order number"),
|
|
126
412
|
},
|
|
@@ -128,9 +414,1067 @@ server.registerTool("get_service_history", {
|
|
|
128
414
|
const { data } = await apiRequest("GET", `/account/services/${orderNo}/history`);
|
|
129
415
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
130
416
|
});
|
|
417
|
+
server.registerTool("get_service_rescue", {
|
|
418
|
+
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.",
|
|
419
|
+
inputSchema: {
|
|
420
|
+
orderNo: serviceOrderNoSchema,
|
|
421
|
+
},
|
|
422
|
+
annotations: {
|
|
423
|
+
title: "Get service rescue status",
|
|
424
|
+
readOnlyHint: true,
|
|
425
|
+
destructiveHint: false,
|
|
426
|
+
idempotentHint: true,
|
|
427
|
+
},
|
|
428
|
+
}, async ({ orderNo }) => {
|
|
429
|
+
const { status, data } = await apiRequest("GET", svc(encodeURIComponent(orderNo), "rescue"));
|
|
430
|
+
return {
|
|
431
|
+
content: [{
|
|
432
|
+
type: "text",
|
|
433
|
+
text: formatJson(safeServiceRescuePayload(status, data)),
|
|
434
|
+
}],
|
|
435
|
+
};
|
|
436
|
+
});
|
|
437
|
+
server.registerTool("enter_service_rescue", {
|
|
438
|
+
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.",
|
|
439
|
+
inputSchema: {
|
|
440
|
+
orderNo: serviceOrderNoSchema,
|
|
441
|
+
image: serviceRescueImageIdSchema,
|
|
442
|
+
acknowledge_reboot: z
|
|
443
|
+
.literal(true)
|
|
444
|
+
.describe("Explicit user approval that rescue entry restarts the service"),
|
|
445
|
+
idempotencyKey: idempotencyKeySchema.describe("Fresh stable key for this exact rescue-entry request"),
|
|
446
|
+
},
|
|
447
|
+
annotations: {
|
|
448
|
+
title: "Enter service rescue mode",
|
|
449
|
+
readOnlyHint: false,
|
|
450
|
+
destructiveHint: true,
|
|
451
|
+
idempotentHint: true,
|
|
452
|
+
},
|
|
453
|
+
}, async ({ orderNo, image, acknowledge_reboot, idempotencyKey }) => {
|
|
454
|
+
if (acknowledge_reboot !== true) {
|
|
455
|
+
throw new Error("Explicit reboot acknowledgement is required.");
|
|
456
|
+
}
|
|
457
|
+
const current = await apiRequest("GET", svc(encodeURIComponent(orderNo), "rescue"));
|
|
458
|
+
const statusPayload = parseServiceRescueStatus(current.data);
|
|
459
|
+
const advertised = statusPayload?.rescue.capability.images.some((candidate) => candidate.id === image);
|
|
460
|
+
if (current.status < 200 ||
|
|
461
|
+
current.status >= 300 ||
|
|
462
|
+
statusPayload?.rescue.capability.enabled !== true ||
|
|
463
|
+
advertised !== true) {
|
|
464
|
+
return {
|
|
465
|
+
isError: true,
|
|
466
|
+
content: [{
|
|
467
|
+
type: "text",
|
|
468
|
+
text: formatJson({
|
|
469
|
+
success: false,
|
|
470
|
+
error_codes: ["serviceRescueImageNotAdvertised"],
|
|
471
|
+
}),
|
|
472
|
+
}],
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
const { status, data } = await apiRequest("POST", svc(encodeURIComponent(orderNo), "rescue"), serviceRescueEnterRequestBody(image), { "Idempotency-Key": idempotencyKey });
|
|
476
|
+
return {
|
|
477
|
+
content: [{
|
|
478
|
+
type: "text",
|
|
479
|
+
text: formatJson(safeServiceRescuePayload(status, data)),
|
|
480
|
+
}],
|
|
481
|
+
};
|
|
482
|
+
});
|
|
483
|
+
server.registerTool("exit_service_rescue", {
|
|
484
|
+
description: "Restore the exact pre-rescue boot configuration and original running or stopped state for one owned service. Read get_service_rescue first and pass its exact session UUID. This may restart the service. Call only after explicit user confirmation. Requires services:rescue, full API-key access, and a stable idempotencyKey. If the same exit reaches needs_attention, retry this tool with the same key.",
|
|
485
|
+
inputSchema: {
|
|
486
|
+
orderNo: serviceOrderNoSchema,
|
|
487
|
+
rescue_session_id: z
|
|
488
|
+
.string()
|
|
489
|
+
.uuid()
|
|
490
|
+
.describe("Exact current session UUID from get_service_rescue"),
|
|
491
|
+
acknowledge_restart: z
|
|
492
|
+
.literal(true)
|
|
493
|
+
.describe("Explicit user approval to restore normal boot state"),
|
|
494
|
+
idempotencyKey: idempotencyKeySchema.describe("Stable key for this exact rescue exit; reuse it for a needs_attention retry"),
|
|
495
|
+
},
|
|
496
|
+
annotations: {
|
|
497
|
+
title: "Exit service rescue mode",
|
|
498
|
+
readOnlyHint: false,
|
|
499
|
+
destructiveHint: true,
|
|
500
|
+
idempotentHint: true,
|
|
501
|
+
},
|
|
502
|
+
}, async ({ orderNo, rescue_session_id, acknowledge_restart, idempotencyKey, }) => {
|
|
503
|
+
if (acknowledge_restart !== true) {
|
|
504
|
+
throw new Error("Explicit restart acknowledgement is required.");
|
|
505
|
+
}
|
|
506
|
+
const current = await apiRequest("GET", svc(encodeURIComponent(orderNo), "rescue"));
|
|
507
|
+
const statusPayload = parseServiceRescueStatus(current.data);
|
|
508
|
+
const currentSession = statusPayload?.rescue.session;
|
|
509
|
+
if (current.status < 200 ||
|
|
510
|
+
current.status >= 300 ||
|
|
511
|
+
!currentSession ||
|
|
512
|
+
currentSession.id !== rescue_session_id ||
|
|
513
|
+
!["active", "needs_attention"].includes(currentSession.state)) {
|
|
514
|
+
return {
|
|
515
|
+
isError: true,
|
|
516
|
+
content: [{
|
|
517
|
+
type: "text",
|
|
518
|
+
text: formatJson({
|
|
519
|
+
success: false,
|
|
520
|
+
error_codes: ["serviceRescueSessionNotCurrent"],
|
|
521
|
+
}),
|
|
522
|
+
}],
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
const { status, data } = await apiRequest("DELETE", svc(encodeURIComponent(orderNo), "rescue"), undefined, { "Idempotency-Key": idempotencyKey });
|
|
526
|
+
return {
|
|
527
|
+
content: [{
|
|
528
|
+
type: "text",
|
|
529
|
+
text: formatJson(safeServiceRescuePayload(status, data)),
|
|
530
|
+
}],
|
|
531
|
+
};
|
|
532
|
+
});
|
|
533
|
+
// --- Managed Applications ---
|
|
534
|
+
server.registerTool("list_application_catalog", {
|
|
535
|
+
description: "List catalog applications compatible with one owned VPSnet service. Use this before a generic SSH installation and before install_application. The response includes application details, container runtime, configuration fields, hard product/OS/architecture/runtime compatibility, and advisory CPU/RAM/disk sizing warnings. Resource guidance alone must not block installation. Two blocks state what an install needs before you attempt it: target.install_requirements.runtime_restart_consent says whether this server still needs explicit restart consent, and each entry's secret_delivery says whether that release generates a password revealed only once and lists those credential NAMES. Those credentials often do not appear in configuration, which lists only customer-editable fields. Requires applications:read.",
|
|
536
|
+
inputSchema: {
|
|
537
|
+
orderNo: applicationOrderNoSchema,
|
|
538
|
+
},
|
|
539
|
+
annotations: {
|
|
540
|
+
title: "List compatible managed applications",
|
|
541
|
+
readOnlyHint: true,
|
|
542
|
+
destructiveHint: false,
|
|
543
|
+
idempotentHint: true,
|
|
544
|
+
},
|
|
545
|
+
}, async ({ orderNo }) => {
|
|
546
|
+
const { data } = await apiRequest("GET", applicationPath(orderNo, "catalog"));
|
|
547
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
548
|
+
});
|
|
549
|
+
server.registerTool("list_application_registry_credentials", {
|
|
550
|
+
description: "List non-secret private registry credential metadata for one owned service, including Docker Hub, GHCR, and exact custom HTTPS registry hostnames. Token values, encrypted envelopes, fingerprints, and key versions are never returned. Registry token creation and rotation are intentionally unavailable through MCP; use the VPSnet panel or direct REST API so the token does not enter model context. Requires applications:read.",
|
|
551
|
+
inputSchema: {
|
|
552
|
+
orderNo: applicationOrderNoSchema,
|
|
553
|
+
},
|
|
554
|
+
annotations: {
|
|
555
|
+
title: "List application registry credentials",
|
|
556
|
+
readOnlyHint: true,
|
|
557
|
+
destructiveHint: false,
|
|
558
|
+
idempotentHint: true,
|
|
559
|
+
},
|
|
560
|
+
}, async ({ orderNo }) => {
|
|
561
|
+
const { status, data } = await apiRequest("GET", applicationPath(orderNo, "registry-credentials"));
|
|
562
|
+
return {
|
|
563
|
+
content: [{
|
|
564
|
+
type: "text",
|
|
565
|
+
text: formatJson(safeApplicationRegistryCredentialPayload(status, data)),
|
|
566
|
+
}],
|
|
567
|
+
};
|
|
568
|
+
});
|
|
569
|
+
server.registerTool("list_service_applications", {
|
|
570
|
+
description: "List managed application installations and any pending checkout selection for one owned service. A queued state is not proof of health. Requires applications:read.",
|
|
571
|
+
inputSchema: {
|
|
572
|
+
orderNo: applicationOrderNoSchema,
|
|
573
|
+
},
|
|
574
|
+
annotations: {
|
|
575
|
+
title: "List service applications",
|
|
576
|
+
readOnlyHint: true,
|
|
577
|
+
destructiveHint: false,
|
|
578
|
+
idempotentHint: true,
|
|
579
|
+
},
|
|
580
|
+
}, async ({ orderNo }) => {
|
|
581
|
+
const { data } = await apiRequest("GET", applicationPath(orderNo, "installations"));
|
|
582
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
583
|
+
});
|
|
584
|
+
server.registerTool("get_application_installation", {
|
|
585
|
+
description: "Get customer-safe observed state, health, drift, endpoints, components, bounded application/container resource history, optional display thresholds, and latest action for one owned managed application. Container samples expose only Compose service and ordinal; Docker identities and internal generation digests are withheld. Requires applications:read.",
|
|
586
|
+
inputSchema: {
|
|
587
|
+
orderNo: applicationOrderNoSchema,
|
|
588
|
+
installation_id: applicationInstallationIdSchema,
|
|
589
|
+
},
|
|
590
|
+
annotations: {
|
|
591
|
+
title: "Get managed application state",
|
|
592
|
+
readOnlyHint: true,
|
|
593
|
+
destructiveHint: false,
|
|
594
|
+
idempotentHint: true,
|
|
595
|
+
},
|
|
596
|
+
}, async ({ orderNo, installation_id }) => {
|
|
597
|
+
const { data } = await apiRequest("GET", applicationPath(orderNo, `installations/${encodeURIComponent(installation_id)}`));
|
|
598
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
599
|
+
});
|
|
600
|
+
server.registerTool("configure_application_resource_thresholds", {
|
|
601
|
+
description: "Replace the complete optional resource-threshold set and email preference for one owned managed application. Read get_application_installation first. Omitted or null numeric fields disable that threshold; omitted email_enabled is false; omitting all four thresholds clears the preferences and disables email. Thresholds highlight measured CPU, memory, combined network-per-minute, and restart-delta samples. When email is enabled, VPSnet sends one account email when a configured threshold is reached and one when it recovers, without repeating while it remains high. Thresholds do not reserve or enforce resources, restart containers, or affect billing. Confirm the exact thresholds and email preference with the user first. Requires applications:manage and is not a paid API-key operation.",
|
|
602
|
+
inputSchema: {
|
|
603
|
+
orderNo: applicationOrderNoSchema,
|
|
604
|
+
installation_id: applicationInstallationIdSchema,
|
|
605
|
+
cpu_percent: applicationResourceCpuPercentSchema,
|
|
606
|
+
email_enabled: applicationResourceEmailEnabledSchema,
|
|
607
|
+
memory_mib: applicationResourceMemoryMiBSchema,
|
|
608
|
+
network_mib_per_minute: applicationResourceNetworkMiBPerMinuteSchema,
|
|
609
|
+
restart_delta: applicationResourceRestartDeltaSchema,
|
|
610
|
+
confirmed: z
|
|
611
|
+
.literal(true)
|
|
612
|
+
.describe("True only after the user confirmed the complete threshold set"),
|
|
613
|
+
},
|
|
614
|
+
annotations: {
|
|
615
|
+
title: "Configure application resource thresholds",
|
|
616
|
+
readOnlyHint: false,
|
|
617
|
+
destructiveHint: false,
|
|
618
|
+
idempotentHint: true,
|
|
619
|
+
},
|
|
620
|
+
}, async ({ orderNo, installation_id, cpu_percent, email_enabled, memory_mib, network_mib_per_minute, restart_delta, }) => {
|
|
621
|
+
const { data } = await apiRequest("PUT", applicationPath(orderNo, `installations/${encodeURIComponent(installation_id)}/resource-thresholds`), applicationResourceThresholdRequestBody({
|
|
622
|
+
cpuPercent: cpu_percent,
|
|
623
|
+
emailEnabled: email_enabled,
|
|
624
|
+
memoryMiB: memory_mib,
|
|
625
|
+
networkMiBPerMinute: network_mib_per_minute,
|
|
626
|
+
restartDelta: restart_delta,
|
|
627
|
+
}));
|
|
628
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
629
|
+
});
|
|
630
|
+
server.registerTool("get_application_events", {
|
|
631
|
+
description: "Get the latest customer-safe audit events for one owned managed application. Use this with get_application_installation to verify asynchronous changes. Requires applications:read.",
|
|
632
|
+
inputSchema: {
|
|
633
|
+
orderNo: applicationOrderNoSchema,
|
|
634
|
+
installation_id: applicationInstallationIdSchema,
|
|
635
|
+
},
|
|
636
|
+
annotations: {
|
|
637
|
+
title: "Get managed application events",
|
|
638
|
+
readOnlyHint: true,
|
|
639
|
+
destructiveHint: false,
|
|
640
|
+
idempotentHint: true,
|
|
641
|
+
},
|
|
642
|
+
}, async ({ orderNo, installation_id }) => {
|
|
643
|
+
const { data } = await apiRequest("GET", applicationPath(orderNo, `installations/${encodeURIComponent(installation_id)}/events`));
|
|
644
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
645
|
+
});
|
|
646
|
+
const INSPECTION_TERMINAL_STATES = new Set(["succeeded", "failed", "expired"]);
|
|
647
|
+
async function inspectionSleep(ms) {
|
|
648
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
649
|
+
}
|
|
650
|
+
// Queue an on-demand health/logs inspection and poll until it reaches a
|
|
651
|
+
// terminal state or the poll budget (~30s) is exhausted. The backend dedupes an
|
|
652
|
+
// already-active inspection of the same kind, so no idempotency key is needed.
|
|
653
|
+
async function runApplicationInspection(orderNo, installationId, kind, body) {
|
|
654
|
+
const basePath = applicationPath(orderNo, `installations/${encodeURIComponent(installationId)}/inspections`);
|
|
655
|
+
const created = await apiRequest("POST", `${basePath}/${kind}`, body);
|
|
656
|
+
if (created.status >= 400) {
|
|
657
|
+
return created;
|
|
658
|
+
}
|
|
659
|
+
const initial = created.data?.inspection;
|
|
660
|
+
const inspectionId = initial?.id;
|
|
661
|
+
if (!inspectionId || INSPECTION_TERMINAL_STATES.has(String(initial?.state))) {
|
|
662
|
+
return created;
|
|
663
|
+
}
|
|
664
|
+
let latest = created;
|
|
665
|
+
for (let attempt = 0; attempt < 15; attempt += 1) {
|
|
666
|
+
await inspectionSleep(2000);
|
|
667
|
+
latest = await apiRequest("GET", `${basePath}/${encodeURIComponent(inspectionId)}`);
|
|
668
|
+
if (latest.status >= 400) {
|
|
669
|
+
return latest;
|
|
670
|
+
}
|
|
671
|
+
const polled = latest.data?.inspection;
|
|
672
|
+
if (polled && INSPECTION_TERMINAL_STATES.has(String(polled.state))) {
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return latest;
|
|
677
|
+
}
|
|
678
|
+
function applicationAsyncEnvelope(data, key) {
|
|
679
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
680
|
+
return {};
|
|
681
|
+
const value = data[key];
|
|
682
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
683
|
+
? value
|
|
684
|
+
: {};
|
|
685
|
+
}
|
|
686
|
+
async function runApplicationAsyncOperation(createPath, pollPath, key, body) {
|
|
687
|
+
const created = await apiRequest("POST", createPath, body);
|
|
688
|
+
if (created.status >= 400)
|
|
689
|
+
return created;
|
|
690
|
+
const initial = applicationAsyncEnvelope(created.data, key);
|
|
691
|
+
if (!initial.id
|
|
692
|
+
|| INSPECTION_TERMINAL_STATES.has(String(initial.state))) {
|
|
693
|
+
return created;
|
|
694
|
+
}
|
|
695
|
+
let latest = created;
|
|
696
|
+
for (let attempt = 0; attempt < 15; attempt += 1) {
|
|
697
|
+
await inspectionSleep(2000);
|
|
698
|
+
latest = await apiRequest("GET", pollPath(encodeURIComponent(initial.id)));
|
|
699
|
+
if (latest.status >= 400)
|
|
700
|
+
return latest;
|
|
701
|
+
const polled = applicationAsyncEnvelope(latest.data, key);
|
|
702
|
+
if (INSPECTION_TERMINAL_STATES.has(String(polled.state)))
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
if (key !== "validation" || latest.status >= 400)
|
|
706
|
+
return latest;
|
|
707
|
+
const createdPayload = created.data && typeof created.data === "object"
|
|
708
|
+
&& !Array.isArray(created.data)
|
|
709
|
+
? created.data
|
|
710
|
+
: {};
|
|
711
|
+
const latestPayload = latest.data && typeof latest.data === "object"
|
|
712
|
+
&& !Array.isArray(latest.data)
|
|
713
|
+
? latest.data
|
|
714
|
+
: {};
|
|
715
|
+
const createdValidation = createdPayload.validation
|
|
716
|
+
&& typeof createdPayload.validation === "object"
|
|
717
|
+
&& !Array.isArray(createdPayload.validation)
|
|
718
|
+
? createdPayload.validation
|
|
719
|
+
: {};
|
|
720
|
+
const latestValidation = latestPayload.validation
|
|
721
|
+
&& typeof latestPayload.validation === "object"
|
|
722
|
+
&& !Array.isArray(latestPayload.validation)
|
|
723
|
+
? latestPayload.validation
|
|
724
|
+
: {};
|
|
725
|
+
return {
|
|
726
|
+
status: latest.status,
|
|
727
|
+
data: {
|
|
728
|
+
...latestPayload,
|
|
729
|
+
replayed: createdPayload.replayed === true,
|
|
730
|
+
validation: {
|
|
731
|
+
...latestValidation,
|
|
732
|
+
source_compose_digest: createdValidation.source_compose_digest,
|
|
733
|
+
resolved_compose_yaml: createdValidation.resolved_compose_yaml,
|
|
734
|
+
image_resolutions: createdValidation.image_resolutions,
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
function applicationRecipeValidationPassed(data) {
|
|
740
|
+
const validation = applicationAsyncEnvelope(data, "validation");
|
|
741
|
+
if (validation.state !== "succeeded")
|
|
742
|
+
return false;
|
|
743
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
744
|
+
return false;
|
|
745
|
+
const value = data.validation;
|
|
746
|
+
return Boolean(value
|
|
747
|
+
&& typeof value === "object"
|
|
748
|
+
&& !Array.isArray(value)
|
|
749
|
+
&& value.valid === true);
|
|
750
|
+
}
|
|
751
|
+
function applicationRecipeResolvedCompose(data) {
|
|
752
|
+
if (!applicationRecipeValidationPassed(data))
|
|
753
|
+
return null;
|
|
754
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
755
|
+
return null;
|
|
756
|
+
const value = data.validation;
|
|
757
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
758
|
+
return null;
|
|
759
|
+
const compose = value.resolved_compose_yaml;
|
|
760
|
+
return typeof compose === "string" && compose.length > 0
|
|
761
|
+
&& compose.length <= 262144
|
|
762
|
+
? compose
|
|
763
|
+
: null;
|
|
764
|
+
}
|
|
765
|
+
server.registerTool("get_application_health", {
|
|
766
|
+
description: "Run an on-demand health inspection of one owned managed application and return the observed container/service health. Use this instead of trusting the last-reported health, which can be stale. This queues an inspection (a POST), so it needs an API key that permits write operations; a read-only key is rejected. Requires applications:read.",
|
|
767
|
+
inputSchema: {
|
|
768
|
+
orderNo: applicationOrderNoSchema,
|
|
769
|
+
installation_id: applicationInstallationIdSchema,
|
|
770
|
+
},
|
|
771
|
+
annotations: {
|
|
772
|
+
title: "Inspect managed application health",
|
|
773
|
+
readOnlyHint: false,
|
|
774
|
+
destructiveHint: false,
|
|
775
|
+
idempotentHint: true,
|
|
776
|
+
},
|
|
777
|
+
}, async ({ orderNo, installation_id }) => {
|
|
778
|
+
const { status, data } = await runApplicationInspection(orderNo, installation_id, "health");
|
|
779
|
+
return {
|
|
780
|
+
content: [{ type: "text", text: safeApplicationInspectionResult(status, data, "health") }],
|
|
781
|
+
};
|
|
782
|
+
});
|
|
783
|
+
server.registerTool("get_application_logs", {
|
|
784
|
+
description: "Run an on-demand logs inspection of one owned managed application and return recent, size-bounded container logs for troubleshooting. Optionally select one exact Compose service; omit it to inspect all services. This queues an inspection (a POST), so it needs an API key that permits write operations; a read-only key is rejected. Requires applications:read.",
|
|
785
|
+
inputSchema: {
|
|
786
|
+
orderNo: applicationOrderNoSchema,
|
|
787
|
+
installation_id: applicationInstallationIdSchema,
|
|
788
|
+
tail_lines: applicationLogTailLinesSchema,
|
|
789
|
+
max_bytes: applicationLogMaxBytesSchema,
|
|
790
|
+
service: applicationLogServiceSchema,
|
|
791
|
+
},
|
|
792
|
+
annotations: {
|
|
793
|
+
title: "Inspect managed application logs",
|
|
794
|
+
readOnlyHint: false,
|
|
795
|
+
destructiveHint: false,
|
|
796
|
+
idempotentHint: true,
|
|
797
|
+
},
|
|
798
|
+
}, async ({ orderNo, installation_id, tail_lines, max_bytes, service }) => {
|
|
799
|
+
const body = {};
|
|
800
|
+
if (typeof tail_lines === "number") {
|
|
801
|
+
body.tailLines = tail_lines;
|
|
802
|
+
}
|
|
803
|
+
if (typeof max_bytes === "number") {
|
|
804
|
+
body.maxBytes = max_bytes;
|
|
805
|
+
}
|
|
806
|
+
if (typeof service === "string") {
|
|
807
|
+
body.service = service;
|
|
808
|
+
}
|
|
809
|
+
const { status, data } = await runApplicationInspection(orderNo, installation_id, "logs", Object.keys(body).length > 0 ? body : undefined);
|
|
810
|
+
return {
|
|
811
|
+
content: [{ type: "text", text: safeApplicationInspectionResult(status, data, "logs") }],
|
|
812
|
+
};
|
|
813
|
+
});
|
|
814
|
+
server.registerTool("list_application_restore_points", {
|
|
815
|
+
description: "Freely list opaque, unexpired nightly whole-VM backup points eligible to restore the exact current revision of one managed application, plus the current non-binding price and account-balance estimate. This does not create a backup, reserve funds, charge the account, or return PBS repository, archive, key, device, or filesystem-path details. It also returns the active restore after a client reload. Initially supported only on Firecracker services. API keys require applications:manage, paid operations enabled, applications:restore paid scope, and full access because account balance is included.",
|
|
816
|
+
inputSchema: {
|
|
817
|
+
orderNo: applicationOrderNoSchema,
|
|
818
|
+
installation_id: applicationInstallationIdSchema,
|
|
819
|
+
},
|
|
820
|
+
annotations: {
|
|
821
|
+
title: "List application restore points",
|
|
822
|
+
readOnlyHint: true,
|
|
823
|
+
destructiveHint: false,
|
|
824
|
+
idempotentHint: true,
|
|
825
|
+
},
|
|
826
|
+
}, async ({ orderNo, installation_id }) => {
|
|
827
|
+
const { status, data } = await apiRequest("GET", applicationPath(orderNo, `installations/${encodeURIComponent(installation_id)}/restore-points`));
|
|
828
|
+
return {
|
|
829
|
+
content: [{
|
|
830
|
+
type: "text",
|
|
831
|
+
text: formatJson(safeApplicationDataRestorePointsPayload(status, data)),
|
|
832
|
+
}],
|
|
833
|
+
};
|
|
834
|
+
});
|
|
835
|
+
server.registerTool("quote_application_data_restore", {
|
|
836
|
+
description: "Create a short-lived quote for one exact application restore selection. This does not charge or restore anything. Read get_application_installation and list_application_restore_points first, then disclose quote.total_charged and available balance to the user. For API keys this requires paid operations enabled, applications:restore, daily/monthly spend caps, and a fresh idempotencyKey that must also be used for restore_application_data.",
|
|
837
|
+
inputSchema: {
|
|
838
|
+
orderNo: applicationOrderNoSchema,
|
|
839
|
+
installation_id: applicationInstallationIdSchema,
|
|
840
|
+
backup_point_id: applicationDataRestorePointIdSchema,
|
|
841
|
+
expected_revision: applicationRevisionSchema,
|
|
842
|
+
idempotencyKey: paidIdempotencyKeySchema.describe("Client-global unique key used for both this quote and its exact restore confirmation."),
|
|
843
|
+
},
|
|
844
|
+
annotations: {
|
|
845
|
+
title: "Quote application data restore",
|
|
846
|
+
readOnlyHint: false,
|
|
847
|
+
destructiveHint: false,
|
|
848
|
+
idempotentHint: true,
|
|
849
|
+
},
|
|
850
|
+
}, async ({ orderNo, installation_id, backup_point_id, expected_revision, idempotencyKey, }) => {
|
|
851
|
+
const { status, data } = await apiRequest("POST", applicationPath(orderNo, `installations/${encodeURIComponent(installation_id)}/restores/quote`), applicationDataRestoreQuoteRequestBody({
|
|
852
|
+
orderNo,
|
|
853
|
+
backupPointId: backup_point_id,
|
|
854
|
+
expectedRevision: expected_revision,
|
|
855
|
+
}), { "Idempotency-Key": idempotencyKey });
|
|
856
|
+
return {
|
|
857
|
+
content: [{
|
|
858
|
+
type: "text",
|
|
859
|
+
text: formatJson(safeApplicationDataRestoreQuotePayload(status, data)),
|
|
860
|
+
}],
|
|
861
|
+
};
|
|
862
|
+
});
|
|
863
|
+
server.registerTool("restore_application_data", {
|
|
864
|
+
description: "Pay for and queue replacement of only the selected managed application's declared data from one eligible nightly server backup. First call quote_application_data_restore, disclose its exact total charge, and obtain explicit approval for both that charge and replacement of current data. Use the exact quote token and same idempotency key. The worker derives every path, excludes secrets and unrelated Docker/server data, and requires rollback capacity. Requires applications:manage; API keys additionally require paid operations, applications:restore, and spend caps.",
|
|
865
|
+
inputSchema: {
|
|
866
|
+
orderNo: applicationOrderNoSchema,
|
|
867
|
+
installation_id: applicationInstallationIdSchema,
|
|
868
|
+
backup_point_id: applicationDataRestorePointIdSchema,
|
|
869
|
+
expected_revision: applicationRevisionSchema,
|
|
870
|
+
acknowledge_data_replacement: z
|
|
871
|
+
.literal(true)
|
|
872
|
+
.describe("True only after the user explicitly confirms replacement of the current declared application data"),
|
|
873
|
+
acknowledge_restore_charge: z
|
|
874
|
+
.literal(true)
|
|
875
|
+
.describe("True only after the user explicitly approves the exact total_charged returned by quote_application_data_restore"),
|
|
876
|
+
quote_token: applicationDataRestoreQuoteTokenSchema,
|
|
877
|
+
idempotencyKey: paidIdempotencyKeySchema.describe("Client-global unique key. Reuse it only to replay this exact restore request."),
|
|
878
|
+
},
|
|
879
|
+
annotations: {
|
|
880
|
+
title: "Restore managed application data",
|
|
881
|
+
readOnlyHint: false,
|
|
882
|
+
destructiveHint: true,
|
|
883
|
+
idempotentHint: true,
|
|
884
|
+
},
|
|
885
|
+
}, async ({ orderNo, installation_id, backup_point_id, expected_revision, quote_token, idempotencyKey, }) => {
|
|
886
|
+
const { status, data } = await apiRequest("POST", applicationPath(orderNo, `installations/${encodeURIComponent(installation_id)}/restores`), applicationDataRestoreRequestBody({
|
|
887
|
+
orderNo,
|
|
888
|
+
backupPointId: backup_point_id,
|
|
889
|
+
expectedRevision: expected_revision,
|
|
890
|
+
quoteToken: quote_token,
|
|
891
|
+
}), { "Idempotency-Key": idempotencyKey });
|
|
892
|
+
return {
|
|
893
|
+
content: [{
|
|
894
|
+
type: "text",
|
|
895
|
+
text: formatJson(safeApplicationDataRestorePayload(status, data)),
|
|
896
|
+
}],
|
|
897
|
+
};
|
|
898
|
+
});
|
|
899
|
+
server.registerTool("get_application_data_restore", {
|
|
900
|
+
description: "Poll one tenant-bound selective application data restore. queued, running, and awaiting_reply remain active. needs_attention is not success and keeps application/service mutations locked until a signed worker result or operator verification resolves it. Requires applications:read.",
|
|
901
|
+
inputSchema: {
|
|
902
|
+
orderNo: applicationOrderNoSchema,
|
|
903
|
+
installation_id: applicationInstallationIdSchema,
|
|
904
|
+
restore_id: applicationDataRestoreIdSchema,
|
|
905
|
+
},
|
|
906
|
+
annotations: {
|
|
907
|
+
title: "Get application data restore",
|
|
908
|
+
readOnlyHint: true,
|
|
909
|
+
destructiveHint: false,
|
|
910
|
+
idempotentHint: true,
|
|
911
|
+
},
|
|
912
|
+
}, async ({ orderNo, installation_id, restore_id }) => {
|
|
913
|
+
const { status, data } = await apiRequest("GET", applicationPath(orderNo, `installations/${encodeURIComponent(installation_id)}/restores/${encodeURIComponent(restore_id)}`));
|
|
914
|
+
return {
|
|
915
|
+
content: [{
|
|
916
|
+
type: "text",
|
|
917
|
+
text: formatJson(safeApplicationDataRestorePayload(status, data)),
|
|
918
|
+
}],
|
|
919
|
+
};
|
|
920
|
+
});
|
|
921
|
+
server.registerTool("validate_application_recipe", {
|
|
922
|
+
description: "Resolve image tags to immutable digests, then validate the resulting customer-owned Docker Compose document with the authoritative policy on the target Firecracker worker. The successful result includes the exact digest-pinned definition to store. This does not create, install, or modify a project or container. The queued validation is polled for about 30 seconds and may still return pending. Requires applications:manage.",
|
|
923
|
+
inputSchema: {
|
|
924
|
+
orderNo: applicationOrderNoSchema,
|
|
925
|
+
compose_yaml: customProjectComposeSchema,
|
|
926
|
+
registry_credential_ids: customProjectRegistryCredentialIdsSchema,
|
|
927
|
+
},
|
|
928
|
+
annotations: {
|
|
929
|
+
title: "Validate customer application recipe",
|
|
930
|
+
readOnlyHint: false,
|
|
931
|
+
destructiveHint: false,
|
|
932
|
+
idempotentHint: true,
|
|
933
|
+
},
|
|
934
|
+
}, async ({ orderNo, compose_yaml, registry_credential_ids }) => {
|
|
935
|
+
const basePath = applicationPath(orderNo, "custom-projects");
|
|
936
|
+
const { status, data } = await runApplicationAsyncOperation(`${basePath}/validate`, (id) => `${basePath}/validations/${id}`, "validation", { compose_yaml, registry_credential_ids });
|
|
937
|
+
return {
|
|
938
|
+
content: [{
|
|
939
|
+
type: "text",
|
|
940
|
+
text: formatJson(safeCustomProjectValidationPayload(status, data)),
|
|
941
|
+
}],
|
|
942
|
+
};
|
|
943
|
+
});
|
|
944
|
+
server.registerTool("list_application_recipes", {
|
|
945
|
+
description: "List immutable customer-owned application recipe projects for one supported service. These are separate from VPSnet catalog blueprints. Requires applications:read.",
|
|
946
|
+
inputSchema: {
|
|
947
|
+
orderNo: applicationOrderNoSchema,
|
|
948
|
+
},
|
|
949
|
+
annotations: {
|
|
950
|
+
title: "List customer application recipes",
|
|
951
|
+
readOnlyHint: true,
|
|
952
|
+
destructiveHint: false,
|
|
953
|
+
idempotentHint: true,
|
|
954
|
+
},
|
|
955
|
+
}, async ({ orderNo }) => {
|
|
956
|
+
const { status, data } = await apiRequest("GET", applicationPath(orderNo, "custom-projects"));
|
|
957
|
+
return {
|
|
958
|
+
content: [{
|
|
959
|
+
type: "text",
|
|
960
|
+
text: formatJson(safeCustomProjectPayload(status, data)),
|
|
961
|
+
}],
|
|
962
|
+
};
|
|
963
|
+
});
|
|
964
|
+
server.registerTool("list_application_recipe_revisions", {
|
|
965
|
+
description: "List immutable revision metadata for one customer-owned application recipe. No Compose content or secret values are returned. Requires applications:read.",
|
|
966
|
+
inputSchema: {
|
|
967
|
+
orderNo: applicationOrderNoSchema,
|
|
968
|
+
project_id: customProjectIdSchema,
|
|
969
|
+
},
|
|
970
|
+
annotations: {
|
|
971
|
+
title: "List customer recipe revisions",
|
|
972
|
+
readOnlyHint: true,
|
|
973
|
+
destructiveHint: false,
|
|
974
|
+
idempotentHint: true,
|
|
975
|
+
},
|
|
976
|
+
}, async ({ orderNo, project_id }) => {
|
|
977
|
+
const { status, data } = await apiRequest("GET", applicationPath(orderNo, `custom-projects/${encodeURIComponent(project_id)}/revisions`));
|
|
978
|
+
return {
|
|
979
|
+
content: [{
|
|
980
|
+
type: "text",
|
|
981
|
+
text: formatJson(safeCustomProjectPayload(status, data)),
|
|
982
|
+
}],
|
|
983
|
+
};
|
|
984
|
+
});
|
|
985
|
+
server.registerTool("create_application_recipe", {
|
|
986
|
+
description: "Resolve image tags, validate the resulting digest-pinned Compose definition, and freeze that exact definition as the first immutable customer-owned recipe revision. This saves a definition only and does not install or start containers. Plain environment values belong in env; secret_names contain names only. Confirm the exact service, name, Compose definition, variable names, and registry bindings first. Requires applications:manage.",
|
|
987
|
+
inputSchema: {
|
|
988
|
+
orderNo: applicationOrderNoSchema,
|
|
989
|
+
name: customProjectNameSchema,
|
|
990
|
+
compose_yaml: customProjectComposeSchema,
|
|
991
|
+
env: customProjectEnvironmentSchema,
|
|
992
|
+
secret_names: customProjectSecretNamesSchema,
|
|
993
|
+
registry_credential_ids: customProjectRegistryCredentialIdsSchema,
|
|
994
|
+
idempotencyKey: idempotencyKeySchema.describe("Client-global unique key. Reuse it only to replay this exact recipe creation request."),
|
|
995
|
+
confirmed: z.literal(true).describe("True only after the user confirmed this immutable recipe definition"),
|
|
996
|
+
},
|
|
997
|
+
annotations: {
|
|
998
|
+
title: "Create customer application recipe",
|
|
999
|
+
readOnlyHint: false,
|
|
1000
|
+
destructiveHint: false,
|
|
1001
|
+
idempotentHint: true,
|
|
1002
|
+
},
|
|
1003
|
+
}, async ({ orderNo, name, compose_yaml, env, secret_names, registry_credential_ids, idempotencyKey, }) => {
|
|
1004
|
+
const basePath = applicationPath(orderNo, "custom-projects");
|
|
1005
|
+
const validation = await runApplicationAsyncOperation(`${basePath}/validate`, (id) => `${basePath}/validations/${id}`, "validation", { compose_yaml, registry_credential_ids });
|
|
1006
|
+
if (validation.status >= 400
|
|
1007
|
+
|| !applicationRecipeValidationPassed(validation.data)) {
|
|
1008
|
+
return {
|
|
1009
|
+
isError: true,
|
|
1010
|
+
content: [{
|
|
1011
|
+
type: "text",
|
|
1012
|
+
text: formatJson(safeCustomProjectValidationPayload(validation.status, validation.data)),
|
|
1013
|
+
}],
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
const resolvedCompose = applicationRecipeResolvedCompose(validation.data);
|
|
1017
|
+
if (resolvedCompose === null) {
|
|
1018
|
+
return {
|
|
1019
|
+
isError: true,
|
|
1020
|
+
content: [{
|
|
1021
|
+
type: "text",
|
|
1022
|
+
text: formatJson(safeCustomProjectValidationPayload(validation.status, validation.data)),
|
|
1023
|
+
}],
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
const { status, data } = await apiRequest("POST", basePath, {
|
|
1027
|
+
name,
|
|
1028
|
+
...customProjectDefinitionRequestBody({
|
|
1029
|
+
compose_yaml: resolvedCompose,
|
|
1030
|
+
env,
|
|
1031
|
+
secret_names,
|
|
1032
|
+
registry_credential_ids,
|
|
1033
|
+
}),
|
|
1034
|
+
}, { "Idempotency-Key": idempotencyKey });
|
|
1035
|
+
return {
|
|
1036
|
+
content: [{
|
|
1037
|
+
type: "text",
|
|
1038
|
+
text: formatJson(safeCustomProjectPayload(status, data)),
|
|
1039
|
+
}],
|
|
1040
|
+
};
|
|
1041
|
+
});
|
|
1042
|
+
server.registerTool("create_application_recipe_revision", {
|
|
1043
|
+
description: "Resolve image tags, validate the resulting digest-pinned Compose definition, and freeze that exact definition as a later immutable customer-owned recipe revision. This does not update the running installation. In-place updates retain the existing secret names and registry bindings for rollback safety; create a separate project when those bindings must change. Requires applications:manage.",
|
|
1044
|
+
inputSchema: {
|
|
1045
|
+
orderNo: applicationOrderNoSchema,
|
|
1046
|
+
project_id: customProjectIdSchema,
|
|
1047
|
+
compose_yaml: customProjectComposeSchema,
|
|
1048
|
+
env: customProjectEnvironmentSchema,
|
|
1049
|
+
secret_names: customProjectSecretNamesSchema,
|
|
1050
|
+
registry_credential_ids: customProjectRegistryCredentialIdsSchema,
|
|
1051
|
+
idempotencyKey: idempotencyKeySchema.describe("Client-global unique key. Reuse it only to replay this exact immutable revision request."),
|
|
1052
|
+
confirmed: z.literal(true).describe("True only after the user confirmed this immutable recipe revision"),
|
|
1053
|
+
},
|
|
1054
|
+
annotations: {
|
|
1055
|
+
title: "Create customer recipe revision",
|
|
1056
|
+
readOnlyHint: false,
|
|
1057
|
+
destructiveHint: false,
|
|
1058
|
+
idempotentHint: true,
|
|
1059
|
+
},
|
|
1060
|
+
}, async ({ orderNo, project_id, compose_yaml, env, secret_names, registry_credential_ids, idempotencyKey, }) => {
|
|
1061
|
+
const basePath = applicationPath(orderNo, "custom-projects");
|
|
1062
|
+
const validation = await runApplicationAsyncOperation(`${basePath}/validate`, (id) => `${basePath}/validations/${id}`, "validation", { compose_yaml, registry_credential_ids });
|
|
1063
|
+
if (validation.status >= 400
|
|
1064
|
+
|| !applicationRecipeValidationPassed(validation.data)) {
|
|
1065
|
+
return {
|
|
1066
|
+
isError: true,
|
|
1067
|
+
content: [{
|
|
1068
|
+
type: "text",
|
|
1069
|
+
text: formatJson(safeCustomProjectValidationPayload(validation.status, validation.data)),
|
|
1070
|
+
}],
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
const resolvedCompose = applicationRecipeResolvedCompose(validation.data);
|
|
1074
|
+
if (resolvedCompose === null) {
|
|
1075
|
+
return {
|
|
1076
|
+
isError: true,
|
|
1077
|
+
content: [{
|
|
1078
|
+
type: "text",
|
|
1079
|
+
text: formatJson(safeCustomProjectValidationPayload(validation.status, validation.data)),
|
|
1080
|
+
}],
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
const { status, data } = await apiRequest("POST", `${basePath}/${encodeURIComponent(project_id)}/revisions`, customProjectDefinitionRequestBody({
|
|
1084
|
+
compose_yaml: resolvedCompose,
|
|
1085
|
+
env,
|
|
1086
|
+
secret_names,
|
|
1087
|
+
registry_credential_ids,
|
|
1088
|
+
}), { "Idempotency-Key": idempotencyKey });
|
|
1089
|
+
return {
|
|
1090
|
+
content: [{
|
|
1091
|
+
type: "text",
|
|
1092
|
+
text: formatJson(safeCustomProjectPayload(status, data)),
|
|
1093
|
+
}],
|
|
1094
|
+
};
|
|
1095
|
+
});
|
|
1096
|
+
server.registerTool("export_application_recipe", {
|
|
1097
|
+
description: "Export one immutable customer-owned recipe revision. Secret values are never returned. VPSnet catalog recipes are not exportable through this or any other MCP tool. Requires applications:read.",
|
|
1098
|
+
inputSchema: {
|
|
1099
|
+
orderNo: applicationOrderNoSchema,
|
|
1100
|
+
project_id: customProjectIdSchema,
|
|
1101
|
+
revision: customProjectRevisionSchema.optional().describe("Exact revision to export; omit for the current revision"),
|
|
1102
|
+
},
|
|
1103
|
+
annotations: {
|
|
1104
|
+
title: "Export customer application recipe",
|
|
1105
|
+
readOnlyHint: true,
|
|
1106
|
+
destructiveHint: false,
|
|
1107
|
+
idempotentHint: true,
|
|
1108
|
+
},
|
|
1109
|
+
}, async ({ orderNo, project_id, revision }) => {
|
|
1110
|
+
const query = revision === undefined
|
|
1111
|
+
? ""
|
|
1112
|
+
: `?revision=${encodeURIComponent(String(revision))}`;
|
|
1113
|
+
const { status, data } = await apiRequest("GET", `${applicationPath(orderNo, `custom-projects/${encodeURIComponent(project_id)}/export`)}${query}`);
|
|
1114
|
+
return {
|
|
1115
|
+
content: [{
|
|
1116
|
+
type: "text",
|
|
1117
|
+
text: formatJson(safeCustomProjectReceiptPayload(status, data)),
|
|
1118
|
+
}],
|
|
1119
|
+
};
|
|
1120
|
+
});
|
|
1121
|
+
server.registerTool("install_application_recipe", {
|
|
1122
|
+
description: "Queue installation of one exact validated customer-owned recipe revision. Confirm the service, project, revision, secret NAMES, and possible first-runtime restart first; never repeat secret values in confirmation text. The result omits secret values and hands status/reveal work to the VPSnet portal. Requires applications:manage.",
|
|
1123
|
+
inputSchema: {
|
|
1124
|
+
orderNo: applicationOrderNoSchema,
|
|
1125
|
+
project_id: customProjectIdSchema,
|
|
1126
|
+
revision: customProjectRevisionSchema,
|
|
1127
|
+
secrets: customProjectSecretsSchema,
|
|
1128
|
+
acknowledge_runtime_restart: z.literal(true).optional().describe("Explicit consent for a possible one-time service restart while the application runtime is prepared"),
|
|
1129
|
+
acknowledge_recipe_risks: z.literal(true).describe("Explicit acknowledgement that this is a customer-owned recipe"),
|
|
1130
|
+
idempotencyKey: idempotencyKeySchema.describe("Client-global unique key. Reuse it only to replay this exact recipe installation."),
|
|
1131
|
+
confirmed: z.literal(true).describe("True only after the user confirmed this exact installation"),
|
|
1132
|
+
},
|
|
1133
|
+
annotations: {
|
|
1134
|
+
title: "Install customer application recipe",
|
|
1135
|
+
readOnlyHint: false,
|
|
1136
|
+
destructiveHint: false,
|
|
1137
|
+
idempotentHint: true,
|
|
1138
|
+
},
|
|
1139
|
+
}, async ({ orderNo, project_id, revision, secrets, acknowledge_runtime_restart, idempotencyKey, }) => {
|
|
1140
|
+
const { status, data } = await apiRequest("POST", applicationPath(orderNo, `custom-projects/${encodeURIComponent(project_id)}/install`), {
|
|
1141
|
+
revision,
|
|
1142
|
+
secrets,
|
|
1143
|
+
acknowledgeCustomRecipeRisks: true,
|
|
1144
|
+
acknowledgeRuntimeRestart: acknowledge_runtime_restart === true,
|
|
1145
|
+
}, { "Idempotency-Key": idempotencyKey });
|
|
1146
|
+
return {
|
|
1147
|
+
content: [{
|
|
1148
|
+
type: "text",
|
|
1149
|
+
text: formatJson(safeCustomProjectInstallPayload(status, data, `/management/service/${encodeURIComponent(orderNo)}/applications`)),
|
|
1150
|
+
}],
|
|
1151
|
+
};
|
|
1152
|
+
});
|
|
1153
|
+
server.registerTool("discover_service_containers", {
|
|
1154
|
+
description: "Run a bounded read-only Docker discovery on one supported Firecracker service. Returns container names, images, state, health, published ports, Compose labels, and whether each container is VPSnet-managed. It never returns environment values or mounts and does not adopt or modify containers. Requires applications:read.",
|
|
1155
|
+
inputSchema: {
|
|
1156
|
+
orderNo: applicationOrderNoSchema,
|
|
1157
|
+
},
|
|
1158
|
+
annotations: {
|
|
1159
|
+
title: "Discover service containers",
|
|
1160
|
+
readOnlyHint: false,
|
|
1161
|
+
destructiveHint: false,
|
|
1162
|
+
idempotentHint: true,
|
|
1163
|
+
},
|
|
1164
|
+
}, async ({ orderNo }) => {
|
|
1165
|
+
const basePath = applicationPath(orderNo, "container-discoveries");
|
|
1166
|
+
const { status, data } = await runApplicationAsyncOperation(basePath, (id) => `${basePath}/${id}`, "discovery");
|
|
1167
|
+
return {
|
|
1168
|
+
content: [{
|
|
1169
|
+
type: "text",
|
|
1170
|
+
text: formatJson(safeContainerDiscoveryPayload(status, data)),
|
|
1171
|
+
}],
|
|
1172
|
+
};
|
|
1173
|
+
});
|
|
1174
|
+
server.registerTool("prepare_application_compose_adoption", {
|
|
1175
|
+
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.",
|
|
1176
|
+
inputSchema: {
|
|
1177
|
+
orderNo: applicationOrderNoSchema,
|
|
1178
|
+
discovery_id: z.string().uuid().describe("Fresh discovery UUID returned by discover_service_containers"),
|
|
1179
|
+
compose_project: composeProjectLabelSchema,
|
|
1180
|
+
},
|
|
1181
|
+
annotations: {
|
|
1182
|
+
title: "Prepare Compose project adoption",
|
|
1183
|
+
readOnlyHint: false,
|
|
1184
|
+
destructiveHint: false,
|
|
1185
|
+
idempotentHint: true,
|
|
1186
|
+
},
|
|
1187
|
+
}, async ({ orderNo, discovery_id, compose_project }) => {
|
|
1188
|
+
const discoveryPath = applicationPath(orderNo, `container-discoveries/${encodeURIComponent(discovery_id)}`);
|
|
1189
|
+
const { status, data } = await runApplicationAsyncOperation(`${discoveryPath}/adoptions`, (id) => applicationPath(orderNo, `compose-adoptions/${id}`), "adoption", { compose_project });
|
|
1190
|
+
return {
|
|
1191
|
+
content: [{
|
|
1192
|
+
type: "text",
|
|
1193
|
+
text: formatJson(safeComposeAdoptionPayload(status, data)),
|
|
1194
|
+
}],
|
|
1195
|
+
};
|
|
1196
|
+
});
|
|
1197
|
+
server.registerTool("get_application_compose_adoption", {
|
|
1198
|
+
description: "Poll one tenant-bound Compose adoption candidate. Inspect eligibility, the reconstructed Compose file, all variable names, container count, and data-volume count before requesting destructive confirmation. Requires applications:read.",
|
|
1199
|
+
inputSchema: {
|
|
1200
|
+
orderNo: applicationOrderNoSchema,
|
|
1201
|
+
adoption_id: composeAdoptionIdSchema,
|
|
1202
|
+
},
|
|
1203
|
+
annotations: {
|
|
1204
|
+
title: "Get Compose project adoption",
|
|
1205
|
+
readOnlyHint: true,
|
|
1206
|
+
destructiveHint: false,
|
|
1207
|
+
idempotentHint: true,
|
|
1208
|
+
},
|
|
1209
|
+
}, async ({ orderNo, adoption_id }) => {
|
|
1210
|
+
const { status, data } = await apiRequest("GET", applicationPath(orderNo, `compose-adoptions/${encodeURIComponent(adoption_id)}`));
|
|
1211
|
+
return {
|
|
1212
|
+
content: [{
|
|
1213
|
+
type: "text",
|
|
1214
|
+
text: formatJson(safeComposeAdoptionPayload(status, data)),
|
|
1215
|
+
}],
|
|
1216
|
+
};
|
|
1217
|
+
});
|
|
1218
|
+
server.registerTool("confirm_application_compose_adoption", {
|
|
1219
|
+
description: "Confirm the one-time takeover of an eligible unexpired Compose candidate. Re-enter every declared variable because VPSnet never reads or copies existing values. This destructive operation revalidates the exact source, stops its containers, and starts a managed replacement with the same verified local named volumes. On failure, source containers resume only after the managed replacement is conclusively contained; otherwise recovery fails closed. The exact external-volume binding remains signed into later lifecycle actions. Confirm the exact service, candidate, reconstructed recipe, variable NAMES, container/volume counts, and source-stop impact with the user first. Never repeat secret values in approval text or results. Requires applications:manage.",
|
|
1220
|
+
inputSchema: {
|
|
1221
|
+
orderNo: applicationOrderNoSchema,
|
|
1222
|
+
adoption_id: composeAdoptionIdSchema,
|
|
1223
|
+
name: customProjectNameSchema,
|
|
1224
|
+
env: customProjectEnvironmentSchema,
|
|
1225
|
+
secrets: customProjectSecretsSchema,
|
|
1226
|
+
registry_credential_ids: customProjectRegistryCredentialIdsSchema,
|
|
1227
|
+
acknowledge_source_stop: z.literal(true).describe("True only after the user approved stopping the exact source containers during takeover"),
|
|
1228
|
+
acknowledge_recipe_risks: z.literal(true).describe("True only after the user approved the reconstructed customer-owned recipe"),
|
|
1229
|
+
acknowledge_runtime_restart: z.literal(true).optional().describe("Explicit consent for a possible one-time service restart while the application runtime is prepared"),
|
|
1230
|
+
idempotencyKey: idempotencyKeySchema.describe("Client-global unique key. Reuse it only to replay this exact adoption confirmation."),
|
|
1231
|
+
confirmed: z.literal(true).describe("True only after the user confirmed this exact destructive takeover"),
|
|
1232
|
+
},
|
|
1233
|
+
annotations: {
|
|
1234
|
+
title: "Confirm Compose project adoption",
|
|
1235
|
+
readOnlyHint: false,
|
|
1236
|
+
destructiveHint: true,
|
|
1237
|
+
idempotentHint: true,
|
|
1238
|
+
},
|
|
1239
|
+
}, async ({ orderNo, adoption_id, name, env, secrets, registry_credential_ids, acknowledge_runtime_restart, idempotencyKey, }) => {
|
|
1240
|
+
const { status, data } = await apiRequest("POST", applicationPath(orderNo, `compose-adoptions/${encodeURIComponent(adoption_id)}/confirm`), {
|
|
1241
|
+
name,
|
|
1242
|
+
env,
|
|
1243
|
+
secrets,
|
|
1244
|
+
registry_credential_ids,
|
|
1245
|
+
acknowledgeSourceStop: true,
|
|
1246
|
+
acknowledgeCustomRecipeRisks: true,
|
|
1247
|
+
acknowledgeRuntimeRestart: acknowledge_runtime_restart === true,
|
|
1248
|
+
}, { "Idempotency-Key": idempotencyKey });
|
|
1249
|
+
return {
|
|
1250
|
+
content: [{
|
|
1251
|
+
type: "text",
|
|
1252
|
+
text: formatJson(safeComposeAdoptionConfirmationPayload(status, data, `/management/service/${encodeURIComponent(orderNo)}/applications`)),
|
|
1253
|
+
}],
|
|
1254
|
+
};
|
|
1255
|
+
});
|
|
1256
|
+
server.registerTool("install_application", {
|
|
1257
|
+
description: "Queue installation of a catalog application using its version-pinned container blueprint. Call list_application_catalog first: target.install_requirements.runtime_restart_consent.required tells you whether this server still needs restart consent, and the entry's secret_delivery.one_time_secrets tells you whether the install generates a password shown only once. Confirm the service, application, release channel, access choice, and configuration variable NAMES with the user first; never repeat variable values in confirmation text. For the first managed application on an existing server, acknowledge_runtime_restart may be true only after explicit consent to a possible one-time server restart. This tool always requests portal secret delivery: a generated password is revealed once, to the user, in the VPSnet panel. It is never returned here, and this tool must never claim on the user's behalf to have received and stored it. When the result reports secret_delivery.pending_reveal or a portal handoff, tell the user a password is waiting and send them to the panel. An install left on private access is reachable only from inside the server, not from a browser: when the user wants to open a web-UI application, request an advertised public mode (usually platform_https) in this call, and after the installation reports healthy, read the resulting public endpoint URL from get_application_installation and relay it to the user together with the panel handoff. This is asynchronous and not a paid API-key operation. Requires applications:manage.",
|
|
1258
|
+
inputSchema: {
|
|
1259
|
+
orderNo: applicationOrderNoSchema,
|
|
1260
|
+
application: applicationSlugSchema,
|
|
1261
|
+
release_channel: z
|
|
1262
|
+
.string()
|
|
1263
|
+
.regex(/^[a-z0-9][a-z0-9-]{0,31}$/)
|
|
1264
|
+
.optional()
|
|
1265
|
+
.describe("Published release channel. Omit it to install the application's own published channel; most catalog applications are not published on a channel named 'stable', so never guess one."),
|
|
1266
|
+
variables: applicationVariablesSchema,
|
|
1267
|
+
acknowledge_runtime_restart: z
|
|
1268
|
+
.literal(true)
|
|
1269
|
+
.optional()
|
|
1270
|
+
.describe("Explicit consent for a possible one-time server restart while the managed runtime is prepared"),
|
|
1271
|
+
access: applicationSingleAccessSchema
|
|
1272
|
+
.optional()
|
|
1273
|
+
.describe("Exact access request using a mode and fields advertised by the selected catalog entry. Omitting it accepts the platform default: public platform_https when VPSnet platform hostnames are available, otherwise private. private keeps the application reachable only from inside the server, so for an application the user will open in a browser prefer an advertised public mode such as platform_https."),
|
|
1274
|
+
idempotencyKey: idempotencyKeySchema.describe("Client-global unique key. Reuse it only to replay this exact installation request; never reuse it for another service or request."),
|
|
1275
|
+
confirmed: z
|
|
1276
|
+
.literal(true)
|
|
1277
|
+
.describe("True only after the user confirmed this installation"),
|
|
1278
|
+
},
|
|
1279
|
+
annotations: {
|
|
1280
|
+
title: "Install managed application",
|
|
1281
|
+
readOnlyHint: false,
|
|
1282
|
+
destructiveHint: false,
|
|
1283
|
+
idempotentHint: true,
|
|
1284
|
+
},
|
|
1285
|
+
}, async ({ orderNo, application, release_channel, variables, acknowledge_runtime_restart, access, idempotencyKey, }) => {
|
|
1286
|
+
const { status, data } = await apiRequest("POST", applicationPath(orderNo, "installations"), applicationInstallRequestBody({
|
|
1287
|
+
application,
|
|
1288
|
+
releaseChannel: release_channel,
|
|
1289
|
+
variables: variables || {},
|
|
1290
|
+
acknowledgeRuntimeRestart: acknowledge_runtime_restart === true,
|
|
1291
|
+
access,
|
|
1292
|
+
}), { "Idempotency-Key": idempotencyKey });
|
|
1293
|
+
return {
|
|
1294
|
+
content: [
|
|
1295
|
+
{
|
|
1296
|
+
type: "text",
|
|
1297
|
+
text: safeApplicationMutationResult(status, data, orderNo),
|
|
1298
|
+
},
|
|
1299
|
+
],
|
|
1300
|
+
};
|
|
1301
|
+
});
|
|
1302
|
+
server.registerTool("configure_application_access", {
|
|
1303
|
+
description: "Queue an access change for one owned managed application. Read get_application_installation first and pass its current revision. If access.request.schema_version=2, preserve every exact endpoint key and configure each endpoint independently; use service and port metadata to match the user's intent instead of prioritizing array order or primary=true. Private removes that route's public listener. Public HTTP exposes it through the server public IP. Managed HTTPS requires an eligible VPSnet-managed DNS zone, subdomain, and explicit DNS approval. External HTTPS only records the customer-managed URL; VPSnet does not configure or validate its DNS, TLS, or reverse proxy. Confirm the exact change with the user first. A queued response must be verified with get_application_installation and get_application_events. Requires applications:manage and is not a paid API-key operation.",
|
|
1304
|
+
inputSchema: {
|
|
1305
|
+
orderNo: applicationOrderNoSchema,
|
|
1306
|
+
installation_id: applicationInstallationIdSchema,
|
|
1307
|
+
access: applicationAccessSchema.describe("One access choice, or schema_version=2 with every exact endpoint key and its independent access choice"),
|
|
1308
|
+
expected_revision: applicationRevisionSchema,
|
|
1309
|
+
idempotencyKey: idempotencyKeySchema.describe("Client-global unique key. Reuse it only to replay this exact access request; never reuse it for another service or request."),
|
|
1310
|
+
confirmed: z
|
|
1311
|
+
.literal(true)
|
|
1312
|
+
.describe("True only after the user confirmed this access change"),
|
|
1313
|
+
},
|
|
1314
|
+
annotations: {
|
|
1315
|
+
title: "Configure application access",
|
|
1316
|
+
readOnlyHint: false,
|
|
1317
|
+
destructiveHint: false,
|
|
1318
|
+
idempotentHint: true,
|
|
1319
|
+
},
|
|
1320
|
+
}, async ({ orderNo, installation_id, access, expected_revision, idempotencyKey, }) => {
|
|
1321
|
+
const { status, data } = await apiRequest("POST", applicationPath(orderNo, `installations/${encodeURIComponent(installation_id)}/configure-access`), applicationAccessConfigurationRequestBody(access, expected_revision), { "Idempotency-Key": idempotencyKey });
|
|
1322
|
+
return {
|
|
1323
|
+
content: [
|
|
1324
|
+
{
|
|
1325
|
+
type: "text",
|
|
1326
|
+
text: safeApplicationMutationResult(status, data, orderNo),
|
|
1327
|
+
},
|
|
1328
|
+
],
|
|
1329
|
+
};
|
|
1330
|
+
});
|
|
1331
|
+
server.registerTool("manage_application", {
|
|
1332
|
+
description: "Queue one supported lifecycle action for an owned managed application. Confirm the exact action with the user first. For update, first read get_application_installation and copy the exact advertised blueprint_version and upstream_version as execution preconditions. The backend still selects and freezes the eligible immutable release; callers cannot choose an arbitrary target. Stop interrupts service. Uninstall permanently deletes the managed containers, configuration, saved credentials, and application data; existing server backups are retained. Uninstall requires acknowledge_data_loss=true after explicit user confirmation. A queued response must be verified with get_application_installation and get_application_events. Selective data restore is a separate operation exposed by list_application_restore_points and restore_application_data. Requires applications:manage and is not a paid API-key operation.",
|
|
1333
|
+
inputSchema: {
|
|
1334
|
+
orderNo: applicationOrderNoSchema,
|
|
1335
|
+
installation_id: applicationInstallationIdSchema,
|
|
1336
|
+
action: applicationActionSchema,
|
|
1337
|
+
idempotencyKey: idempotencyKeySchema.describe("Client-global unique key. Reuse it only to replay this exact lifecycle request; never reuse it for another service or request."),
|
|
1338
|
+
expected_blueprint_version: applicationExpectedVersionSchema
|
|
1339
|
+
.optional()
|
|
1340
|
+
.describe("Required for update: exact blueprint_version from the current available_actions update capability"),
|
|
1341
|
+
expected_upstream_version: applicationExpectedVersionSchema
|
|
1342
|
+
.optional()
|
|
1343
|
+
.describe("Required for update: exact upstream_version from the current available_actions update capability"),
|
|
1344
|
+
confirmed: z
|
|
1345
|
+
.literal(true)
|
|
1346
|
+
.describe("True only after the user confirmed this lifecycle action"),
|
|
1347
|
+
acknowledge_data_loss: z
|
|
1348
|
+
.literal(true)
|
|
1349
|
+
.optional()
|
|
1350
|
+
.describe("Required for uninstall only. Set true after the user explicitly confirms permanent deletion of managed configuration, credentials, and application data."),
|
|
1351
|
+
},
|
|
1352
|
+
annotations: {
|
|
1353
|
+
title: "Manage application lifecycle",
|
|
1354
|
+
readOnlyHint: false,
|
|
1355
|
+
destructiveHint: true,
|
|
1356
|
+
idempotentHint: true,
|
|
1357
|
+
},
|
|
1358
|
+
}, async ({ orderNo, installation_id, action, idempotencyKey, expected_blueprint_version, expected_upstream_version, acknowledge_data_loss, }) => {
|
|
1359
|
+
if (action === "uninstall" && acknowledge_data_loss !== true) {
|
|
1360
|
+
return {
|
|
1361
|
+
isError: true,
|
|
1362
|
+
content: [
|
|
1363
|
+
{
|
|
1364
|
+
type: "text",
|
|
1365
|
+
text: formatJson({
|
|
1366
|
+
success: false,
|
|
1367
|
+
error_codes: ["applicationUninstallConfirmationRequired"],
|
|
1368
|
+
}),
|
|
1369
|
+
},
|
|
1370
|
+
],
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
if (action === "update") {
|
|
1374
|
+
if (!expected_blueprint_version || !expected_upstream_version) {
|
|
1375
|
+
return {
|
|
1376
|
+
isError: true,
|
|
1377
|
+
content: [{
|
|
1378
|
+
type: "text",
|
|
1379
|
+
text: formatJson({
|
|
1380
|
+
success: false,
|
|
1381
|
+
error_codes: ["applicationUpdateExpectationRequired"],
|
|
1382
|
+
}),
|
|
1383
|
+
}],
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
const current = await apiRequest("GET", applicationPath(orderNo, `installations/${encodeURIComponent(installation_id)}`));
|
|
1387
|
+
if (current.status < 200
|
|
1388
|
+
|| current.status >= 300
|
|
1389
|
+
|| !applicationUpdateCandidateMatches(current.data, {
|
|
1390
|
+
blueprintVersion: expected_blueprint_version,
|
|
1391
|
+
upstreamVersion: expected_upstream_version,
|
|
1392
|
+
})) {
|
|
1393
|
+
return {
|
|
1394
|
+
isError: true,
|
|
1395
|
+
content: [{
|
|
1396
|
+
type: "text",
|
|
1397
|
+
text: formatJson({
|
|
1398
|
+
success: false,
|
|
1399
|
+
error_codes: ["applicationUpdateExpectationChanged"],
|
|
1400
|
+
}),
|
|
1401
|
+
}],
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
else if (expected_blueprint_version || expected_upstream_version) {
|
|
1406
|
+
return {
|
|
1407
|
+
isError: true,
|
|
1408
|
+
content: [{
|
|
1409
|
+
type: "text",
|
|
1410
|
+
text: formatJson({
|
|
1411
|
+
success: false,
|
|
1412
|
+
error_codes: ["applicationUpdateExpectationUnexpected"],
|
|
1413
|
+
}),
|
|
1414
|
+
}],
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
const { status, data } = await apiRequest("POST", applicationPath(orderNo, `installations/${encodeURIComponent(installation_id)}/actions`), applicationLifecycleRequestBody(action, acknowledge_data_loss === true, action === "update"
|
|
1418
|
+
? {
|
|
1419
|
+
blueprintVersion: expected_blueprint_version,
|
|
1420
|
+
upstreamVersion: expected_upstream_version,
|
|
1421
|
+
}
|
|
1422
|
+
: undefined), { "Idempotency-Key": idempotencyKey });
|
|
1423
|
+
return {
|
|
1424
|
+
content: [
|
|
1425
|
+
{
|
|
1426
|
+
type: "text",
|
|
1427
|
+
text: safeApplicationMutationResult(status, data, orderNo),
|
|
1428
|
+
},
|
|
1429
|
+
],
|
|
1430
|
+
};
|
|
1431
|
+
});
|
|
1432
|
+
server.registerTool("cancel_application_action", {
|
|
1433
|
+
description: "Request pre-dispatch cancellation of the exact latest action for one owned managed application. This is available only while get_application_installation advertises latest_action.cancellable=true for the same action UUID. The tool re-reads the installation immediately before submitting the cancellation and refuses stale, different, or non-cancellable actions. Cancellation never stops or interrupts a running worker job. Requires applications:manage, the exact latest_action.id UUID, and a fresh client-global idempotencyKey that was not used for the original action or another request. Reuse that key only to replay this exact cancellation request. This is not a paid API-key operation.",
|
|
1434
|
+
inputSchema: {
|
|
1435
|
+
orderNo: applicationOrderNoSchema,
|
|
1436
|
+
installation_id: applicationInstallationIdSchema,
|
|
1437
|
+
action_id: applicationActionIdSchema,
|
|
1438
|
+
idempotencyKey: idempotencyKeySchema.describe("Fresh client-global unique key for this cancellation request. Do not reuse the original action's key or a key from another request; reuse it only to replay this exact cancellation request."),
|
|
1439
|
+
},
|
|
1440
|
+
annotations: {
|
|
1441
|
+
title: "Cancel queued application action",
|
|
1442
|
+
readOnlyHint: false,
|
|
1443
|
+
destructiveHint: false,
|
|
1444
|
+
idempotentHint: true,
|
|
1445
|
+
},
|
|
1446
|
+
}, async ({ orderNo, installation_id, action_id, idempotencyKey }) => {
|
|
1447
|
+
const installationPath = applicationPath(orderNo, `installations/${encodeURIComponent(installation_id)}`);
|
|
1448
|
+
const current = await apiRequest("GET", installationPath);
|
|
1449
|
+
if (current.status < 200 ||
|
|
1450
|
+
current.status >= 300 ||
|
|
1451
|
+
!applicationActionCancellationIsAdvertised(current.data, action_id)) {
|
|
1452
|
+
return {
|
|
1453
|
+
isError: true,
|
|
1454
|
+
content: [
|
|
1455
|
+
{
|
|
1456
|
+
type: "text",
|
|
1457
|
+
text: formatJson({
|
|
1458
|
+
success: false,
|
|
1459
|
+
error_codes: ["applicationActionCancellationNotAdvertised"],
|
|
1460
|
+
}),
|
|
1461
|
+
},
|
|
1462
|
+
],
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
const { status, data } = await apiRequest("POST", `${installationPath}/actions/${encodeURIComponent(action_id)}/cancel`, applicationActionCancellationRequestBody(), { "Idempotency-Key": idempotencyKey });
|
|
1466
|
+
return {
|
|
1467
|
+
content: [
|
|
1468
|
+
{
|
|
1469
|
+
type: "text",
|
|
1470
|
+
text: safeApplicationMutationResult(status, data, orderNo),
|
|
1471
|
+
},
|
|
1472
|
+
],
|
|
1473
|
+
};
|
|
1474
|
+
});
|
|
131
1475
|
// --- Service Actions ---
|
|
132
1476
|
server.registerTool("start_service", {
|
|
133
|
-
description: "Start a stopped VPS. Returns
|
|
1477
|
+
description: "Start a stopped VPS. Returns a numeric event id; follow it with wait_for_event. Requires services:manage and a full-access API key.",
|
|
134
1478
|
inputSchema: {
|
|
135
1479
|
orderNo: z.string().describe("Order number"),
|
|
136
1480
|
},
|
|
@@ -139,7 +1483,7 @@ server.registerTool("start_service", {
|
|
|
139
1483
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
140
1484
|
});
|
|
141
1485
|
server.registerTool("stop_service", {
|
|
142
|
-
description: "Stop a running VPS. Returns
|
|
1486
|
+
description: "Stop a running VPS. Returns a numeric event id; follow it with wait_for_event. Requires services:manage and a full-access API key.",
|
|
143
1487
|
inputSchema: {
|
|
144
1488
|
orderNo: z.string().describe("Order number"),
|
|
145
1489
|
},
|
|
@@ -148,7 +1492,7 @@ server.registerTool("stop_service", {
|
|
|
148
1492
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
149
1493
|
});
|
|
150
1494
|
server.registerTool("restart_service", {
|
|
151
|
-
description: "Restart a VPS. Returns
|
|
1495
|
+
description: "Restart a VPS. Returns a numeric event id; follow it with wait_for_event. Requires services:manage and a full-access API key.",
|
|
152
1496
|
inputSchema: {
|
|
153
1497
|
orderNo: z.string().describe("Order number"),
|
|
154
1498
|
},
|
|
@@ -156,19 +1500,64 @@ server.registerTool("restart_service", {
|
|
|
156
1500
|
const { data } = await apiRequest("POST", svc(orderNo, "restart"));
|
|
157
1501
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
158
1502
|
});
|
|
1503
|
+
server.registerTool("console_service", {
|
|
1504
|
+
description: "Open VNC console access to a running VPS. Read-ish: it requests a console session and returns the tracking event ID (a console URL/token is delivered out-of-band). The service must be running. Requires services:manage and a full-access API key.",
|
|
1505
|
+
inputSchema: {
|
|
1506
|
+
orderNo: z.string().describe("Order number"),
|
|
1507
|
+
},
|
|
1508
|
+
}, async ({ orderNo }) => {
|
|
1509
|
+
const { data } = await apiRequest("POST", svc(orderNo, "console"));
|
|
1510
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
1511
|
+
});
|
|
1512
|
+
server.registerTool("suspend_service", {
|
|
1513
|
+
description: "Suspend a running Cloud VPS (KVM/VDS) service. Changes service state to suspended. Returns a tracking event ID. VDS/Cloud VPS only; the service must be running. Requires services:manage and a full-access API key.",
|
|
1514
|
+
inputSchema: {
|
|
1515
|
+
orderNo: z.string().describe("Order number"),
|
|
1516
|
+
},
|
|
1517
|
+
}, async ({ orderNo }) => {
|
|
1518
|
+
const { data } = await apiRequest("POST", svc(orderNo, "suspend"));
|
|
1519
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
1520
|
+
});
|
|
1521
|
+
server.registerTool("resume_service", {
|
|
1522
|
+
description: "Resume a suspended Cloud VPS (KVM/VDS) service. Changes service state back to running. Returns a tracking event ID. VDS/Cloud VPS only; the service must be suspended. Requires services:manage and a full-access API key.",
|
|
1523
|
+
inputSchema: {
|
|
1524
|
+
orderNo: z.string().describe("Order number"),
|
|
1525
|
+
},
|
|
1526
|
+
}, async ({ orderNo }) => {
|
|
1527
|
+
const { data } = await apiRequest("POST", svc(orderNo, "resume"));
|
|
1528
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
1529
|
+
});
|
|
159
1530
|
// --- Service Settings ---
|
|
1531
|
+
server.registerTool("get_hostname", {
|
|
1532
|
+
description: "Get the current service hostname, reserved automatic hostname, and automatic/customer management mode. Read-only API keys are accepted. Requires services:read when called with an API key.",
|
|
1533
|
+
inputSchema: {
|
|
1534
|
+
orderNo: serviceOrderNoSchema,
|
|
1535
|
+
},
|
|
1536
|
+
}, async ({ orderNo }) => {
|
|
1537
|
+
const { data } = await apiRequest("GET", svc(orderNo, "change-hostname"));
|
|
1538
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
1539
|
+
});
|
|
160
1540
|
server.registerTool("change_hostname", {
|
|
161
|
-
description: "
|
|
1541
|
+
description: "Queue a customer-managed hostname change for a Container VPS, VPS, or Cloud VPS. VPSnet-managed vpsnet.cloud names are reserved. The returned noty UUID tracks the asynchronous action (get_event / wait_for_event). Also refused (422 reservedHostname): localhost, localdomain, www, ns and ns0-ns99 as bare names, names below .localhost or .internal, and names containing vpsnet. The hostname rule: at least 3 characters, at most 5 dot-separated labels of 1-30 letters, digits or inner hyphens. Requires services:manage and a full-access API key; this route is not idempotency-keyed.",
|
|
162
1542
|
inputSchema: {
|
|
163
|
-
orderNo:
|
|
164
|
-
hostname:
|
|
1543
|
+
orderNo: serviceOrderNoSchema,
|
|
1544
|
+
hostname: serviceHostnameSchema,
|
|
165
1545
|
},
|
|
166
1546
|
}, async ({ orderNo, hostname }) => {
|
|
167
1547
|
const { data } = await apiRequest("POST", svc(orderNo, "change-hostname"), { hostname });
|
|
168
1548
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
169
1549
|
});
|
|
1550
|
+
server.registerTool("reset_hostname", {
|
|
1551
|
+
description: "Restore the immutable VPSnet-managed automatic hostname for a Container VPS, VPS, or Cloud VPS. Returns noty=null when no guest change is needed. Requires services:manage and a full-access API key; this route is not idempotency-keyed.",
|
|
1552
|
+
inputSchema: {
|
|
1553
|
+
orderNo: serviceOrderNoSchema,
|
|
1554
|
+
},
|
|
1555
|
+
}, async ({ orderNo }) => {
|
|
1556
|
+
const { data } = await apiRequest("POST", svc(orderNo, "change-hostname/reset"));
|
|
1557
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
1558
|
+
});
|
|
170
1559
|
server.registerTool("change_root_password", {
|
|
171
|
-
description: "Change VPS root password. Rules: 6-40 chars, alphanumeric, MUST contain uppercase + lowercase + digit. Example: 'MyPass123'.",
|
|
1560
|
+
description: "Change VPS root password. Rules: 6-40 chars, alphanumeric, MUST contain uppercase + lowercase + digit. Example: 'MyPass123'. Requires services:manage and a full-access API key.",
|
|
172
1561
|
inputSchema: {
|
|
173
1562
|
orderNo: z.string().describe("Order number"),
|
|
174
1563
|
password: z
|
|
@@ -180,31 +1569,37 @@ server.registerTool("change_root_password", {
|
|
|
180
1569
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
181
1570
|
});
|
|
182
1571
|
server.registerTool("get_rdns", {
|
|
183
|
-
description: "Get
|
|
1572
|
+
description: "Get effective PTR records for every assigned IPv4 and enabled IPv6 address. The response identifies the automatic default and whether each value is a customer override. Read-only API keys are accepted. Requires services:read when called with an API key.",
|
|
184
1573
|
inputSchema: {
|
|
185
|
-
orderNo:
|
|
1574
|
+
orderNo: serviceOrderNoSchema,
|
|
186
1575
|
},
|
|
187
1576
|
}, async ({ orderNo }) => {
|
|
188
1577
|
const { data } = await apiRequest("GET", svc(orderNo, "change-rdns"));
|
|
189
1578
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
190
1579
|
});
|
|
191
1580
|
server.registerTool("change_rdns", {
|
|
192
|
-
description: "
|
|
1581
|
+
description: "Set a customer PTR override for an address returned by get_rdns. Canonical length is 3-253 characters; labels are 1-63 ASCII alphanumeric/hyphen characters without leading/trailing hyphens. A trailing dot is removed and reserved VPSnet names are blocked. Requires services:manage and a full-access API key; this route is not idempotency-keyed.",
|
|
193
1582
|
inputSchema: {
|
|
194
|
-
orderNo:
|
|
195
|
-
ip:
|
|
196
|
-
|
|
197
|
-
.describe("IP address to set rDNS for. Must belong to this service (check get_rdns)"),
|
|
198
|
-
value: z
|
|
199
|
-
.string()
|
|
200
|
-
.describe("New rDNS value (hostname). Valid FQDN, e.g. 'mail.example.com'. Labels: 1-30 chars, alphanumeric+hyphen, no leading/trailing hyphens. Cannot contain 'vpsnet' or 'speedy'"),
|
|
1583
|
+
orderNo: serviceOrderNoSchema,
|
|
1584
|
+
ip: serviceIpSchema,
|
|
1585
|
+
value: servicePtrSchema,
|
|
201
1586
|
},
|
|
202
1587
|
}, async ({ orderNo, ip, value }) => {
|
|
203
1588
|
const { data } = await apiRequest("POST", svc(orderNo, "change-rdns"), { ip, value });
|
|
204
1589
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
205
1590
|
});
|
|
1591
|
+
server.registerTool("clear_rdns", {
|
|
1592
|
+
description: "Clear a customer PTR override for an address returned by get_rdns. The automatic VPSnet PTR is restored, or PTR is removed when no automatic hostname exists. Requires services:manage and a full-access API key; this route is not idempotency-keyed.",
|
|
1593
|
+
inputSchema: {
|
|
1594
|
+
orderNo: serviceOrderNoSchema,
|
|
1595
|
+
ip: serviceIpSchema,
|
|
1596
|
+
},
|
|
1597
|
+
}, async ({ orderNo, ip }) => {
|
|
1598
|
+
const { data } = await apiRequest("POST", svc(orderNo, "change-rdns/clear"), { ip });
|
|
1599
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
1600
|
+
});
|
|
206
1601
|
server.registerTool("flush_iptables", {
|
|
207
|
-
description: "Flush iptables rules on VPS (useful when locked out)",
|
|
1602
|
+
description: "Flush iptables rules on VPS (useful when locked out) Requires services:manage and a full-access API key.",
|
|
208
1603
|
inputSchema: {
|
|
209
1604
|
orderNo: z.string().describe("Order number"),
|
|
210
1605
|
},
|
|
@@ -212,18 +1607,32 @@ server.registerTool("flush_iptables", {
|
|
|
212
1607
|
const { data } = await apiRequest("POST", svc(orderNo, "flush-ip-tables"));
|
|
213
1608
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
214
1609
|
});
|
|
1610
|
+
server.registerTool("get_title", {
|
|
1611
|
+
description: "Get the current customer-visible service display title Requires services:read when called with an API key.",
|
|
1612
|
+
inputSchema: {
|
|
1613
|
+
orderNo: serviceOrderNoSchema,
|
|
1614
|
+
},
|
|
1615
|
+
}, async ({ orderNo }) => {
|
|
1616
|
+
const { data } = await apiRequest("GET", svc(orderNo, "change-title"));
|
|
1617
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
1618
|
+
});
|
|
215
1619
|
server.registerTool("change_title", {
|
|
216
|
-
description: "Change service display title",
|
|
1620
|
+
description: "Change service display title Requires services:manage and a full-access API key.",
|
|
217
1621
|
inputSchema: {
|
|
218
|
-
orderNo:
|
|
219
|
-
title: z
|
|
1622
|
+
orderNo: serviceOrderNoSchema,
|
|
1623
|
+
title: z
|
|
1624
|
+
.string()
|
|
1625
|
+
.trim()
|
|
1626
|
+
.min(3)
|
|
1627
|
+
.max(25)
|
|
1628
|
+
.describe("New customer-visible display title, 3-25 characters"),
|
|
220
1629
|
},
|
|
221
1630
|
}, async ({ orderNo, title }) => {
|
|
222
1631
|
const { data } = await apiRequest("POST", svc(orderNo, "change-title"), { title });
|
|
223
1632
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
224
1633
|
});
|
|
225
1634
|
server.registerTool("toggle_ipv6", {
|
|
226
|
-
description: "Enable or disable IPv6 on VPS",
|
|
1635
|
+
description: "Enable or disable IPv6 on VPS Requires services:manage and a full-access API key.",
|
|
227
1636
|
inputSchema: {
|
|
228
1637
|
orderNo: z.string().describe("Order number"),
|
|
229
1638
|
value: z.boolean().describe("true to enable, false to disable"),
|
|
@@ -233,7 +1642,7 @@ server.registerTool("toggle_ipv6", {
|
|
|
233
1642
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
234
1643
|
});
|
|
235
1644
|
server.registerTool("toggle_extra_settings", {
|
|
236
|
-
description: "Toggle extra VPS settings: ppp, fuse, tuntap, or nfs",
|
|
1645
|
+
description: "Toggle extra VPS settings: ppp, fuse, tuntap, or nfs Requires services:manage and a full-access API key.",
|
|
237
1646
|
inputSchema: {
|
|
238
1647
|
orderNo: z.string().describe("Order number"),
|
|
239
1648
|
name: z
|
|
@@ -246,7 +1655,7 @@ server.registerTool("toggle_extra_settings", {
|
|
|
246
1655
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
247
1656
|
});
|
|
248
1657
|
server.registerTool("deploy_ssh_key", {
|
|
249
|
-
description: "Deploy an SSH key to VPS. Returns noty UUID
|
|
1658
|
+
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. Requires services:manage and a full-access API key.",
|
|
250
1659
|
inputSchema: {
|
|
251
1660
|
orderNo: z.string().describe("Order number"),
|
|
252
1661
|
ssh_key: z.number().describe("SSH key ID from list_ssh_keys"),
|
|
@@ -257,7 +1666,7 @@ server.registerTool("deploy_ssh_key", {
|
|
|
257
1666
|
});
|
|
258
1667
|
// --- OS Reinstall ---
|
|
259
1668
|
server.registerTool("get_os_options", {
|
|
260
|
-
description: "Get available OS templates for reinstall",
|
|
1669
|
+
description: "Get available OS templates for reinstall Requires services:read when called with an API key.",
|
|
261
1670
|
inputSchema: {
|
|
262
1671
|
orderNo: z.string().describe("Order number"),
|
|
263
1672
|
},
|
|
@@ -266,7 +1675,7 @@ server.registerTool("get_os_options", {
|
|
|
266
1675
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
267
1676
|
});
|
|
268
1677
|
server.registerTool("reinstall_os", {
|
|
269
|
-
description: "Reinstall OS on VPS. WARNING: destroys all data! Returns noty UUID. Password rules: 6-40 chars, alphanumeric, must contain uppercase + lowercase + digit.",
|
|
1678
|
+
description: "Reinstall OS on VPS. WARNING: destroys all data! If the service supports snapshots (Cloud VPS or Firecracker VPS), take one first — it's free for an initial window, so it's cheap insurance you can roll back to; then DELETE it once the reinstall succeeds, because after the free window it is billed per GB while kept (Cloud VPS snapshots do NOT auto-expire) — never leave snapshots lying around. Container VPS and Dedicated have no snapshots, so there is no rollback safety net — confirm with the user before reinstalling. Returns noty UUID. Password rules: 6-40 chars, alphanumeric, must contain uppercase + lowercase + digit. Requires services:manage and a full-access API key.",
|
|
270
1679
|
inputSchema: {
|
|
271
1680
|
orderNo: z.string().describe("Order number"),
|
|
272
1681
|
osVersion: z
|
|
@@ -286,7 +1695,7 @@ server.registerTool("reinstall_os", {
|
|
|
286
1695
|
});
|
|
287
1696
|
// --- Plan Change (FREE) ---
|
|
288
1697
|
server.registerTool("get_plan_options", {
|
|
289
|
-
description: "Get available plans for upgrade/downgrade. Plan changes are FREE.",
|
|
1698
|
+
description: "Get available plans for upgrade/downgrade. Plan changes are FREE. Requires services:read when called with an API key.",
|
|
290
1699
|
inputSchema: {
|
|
291
1700
|
orderNo: z.string().describe("Order number"),
|
|
292
1701
|
},
|
|
@@ -295,7 +1704,7 @@ server.registerTool("get_plan_options", {
|
|
|
295
1704
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
296
1705
|
});
|
|
297
1706
|
server.registerTool("get_plan_resources", {
|
|
298
|
-
description: "Get configurable resources for a specific plan",
|
|
1707
|
+
description: "Get configurable resources for a specific plan Requires services:read when called with an API key.",
|
|
299
1708
|
inputSchema: {
|
|
300
1709
|
orderNo: z.string().describe("Order number"),
|
|
301
1710
|
plan: z.number().describe("Plan ID from get_plan_options"),
|
|
@@ -305,26 +1714,26 @@ server.registerTool("get_plan_resources", {
|
|
|
305
1714
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
306
1715
|
});
|
|
307
1716
|
server.registerTool("calculate_plan_change", {
|
|
308
|
-
description: "Preview plan change
|
|
1717
|
+
description: "Preview a plan change. No payment is taken: the unused value of the current plan is converted at the new plan's price, so an upgrade moves the expiry EARLIER and a downgrade moves it later. Returns expirationDate, currentExpirationDate, expirationChangeDays (negative = shorter) and paymentRequired=false; show the customer both dates. Use get_plan_resources first to see available resource IDs for the target plan. Requires services:read when called with an API key.",
|
|
309
1718
|
inputSchema: {
|
|
310
1719
|
orderNo: z.string().describe("Order number"),
|
|
311
1720
|
plan: z.number().describe("Plan ID from get_plan_options"),
|
|
312
1721
|
resources: z
|
|
313
1722
|
.array(z.number())
|
|
314
|
-
.describe("Array of numeric resource value IDs — one per resource type (RAM, SSD, IP, etc.). Get IDs from get_plan_resources response: each resource type has 'values' array, pick one value's 'id' per type.
|
|
1723
|
+
.describe("Array of numeric resource value IDs — one per resource type (RAM, SSD, IP, etc.). Get IDs from get_plan_resources response: each resource type has 'values' array, pick one value's 'id' per type. A type left out keeps its current value; an id the target plan does not offer is refused with 422 invalidResources."),
|
|
315
1724
|
},
|
|
316
1725
|
}, async ({ orderNo, plan, resources }) => {
|
|
317
|
-
const { data } = await apiRequest("POST", svc(orderNo, "plans-options/calculate"), { plan, resources });
|
|
1726
|
+
const { data } = await apiRequest("POST", svc(orderNo, "plans-options/calculate"), { plan, resources }, undefined, { readOnly: true });
|
|
318
1727
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
319
1728
|
});
|
|
320
1729
|
server.registerTool("change_plan", {
|
|
321
|
-
description: "Change VPS plan
|
|
1730
|
+
description: "Change VPS plan. No payment is taken; the remaining value is converted at the new price, so an upgrade shortens the service period. Always call calculate_plan_change first and confirm the new expiry with the customer. Use get_plan_resources to get resource IDs for the target plan. Requires services:manage and a full-access API key.",
|
|
322
1731
|
inputSchema: {
|
|
323
1732
|
orderNo: z.string().describe("Order number"),
|
|
324
1733
|
plan: z.number().describe("Plan ID from get_plan_options"),
|
|
325
1734
|
resources: z
|
|
326
1735
|
.array(z.number())
|
|
327
|
-
.describe("Array of numeric resource value IDs — one per resource type (RAM, SSD, IP, etc.). Get IDs from get_plan_resources response: each resource type has 'values' array, pick one value's 'id' per type.
|
|
1736
|
+
.describe("Array of numeric resource value IDs — one per resource type (RAM, SSD, IP, etc.). Get IDs from get_plan_resources response: each resource type has 'values' array, pick one value's 'id' per type. A type left out keeps its current value; an id the target plan does not offer is refused with 422 invalidResources."),
|
|
328
1737
|
},
|
|
329
1738
|
}, async ({ orderNo, plan, resources }) => {
|
|
330
1739
|
const { data } = await apiRequest("POST", svc(orderNo, "plans-options"), { plan, resources });
|
|
@@ -332,7 +1741,7 @@ server.registerTool("change_plan", {
|
|
|
332
1741
|
});
|
|
333
1742
|
// --- Period & Renewal ---
|
|
334
1743
|
server.registerTool("get_period_options", {
|
|
335
|
-
description: "Get billing period and auto-renewal options",
|
|
1744
|
+
description: "Get billing period and auto-renewal options Requires services:read when called with an API key.",
|
|
336
1745
|
inputSchema: {
|
|
337
1746
|
orderNo: z.string().describe("Order number"),
|
|
338
1747
|
},
|
|
@@ -341,7 +1750,7 @@ server.registerTool("get_period_options", {
|
|
|
341
1750
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
342
1751
|
});
|
|
343
1752
|
server.registerTool("set_auto_renew", {
|
|
344
|
-
description: "Enable or disable auto-renewal for a service",
|
|
1753
|
+
description: "Enable or disable auto-renewal for a service. Note: enabling auto-renewal will automatically charge the account balance at each renewal (creating an invoice) without further confirmation. Requires services:manage and a full-access API key.",
|
|
345
1754
|
inputSchema: {
|
|
346
1755
|
orderNo: z.string().describe("Order number"),
|
|
347
1756
|
state: z.boolean().describe("true to enable, false to disable"),
|
|
@@ -358,7 +1767,7 @@ server.registerTool("set_auto_renew", {
|
|
|
358
1767
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
359
1768
|
});
|
|
360
1769
|
server.registerTool("renew_service", {
|
|
361
|
-
description: "Manually renew a service for a specific period. Payment object: { payment: 1, successUrl: '', cancelUrl: '' } for balance payment.",
|
|
1770
|
+
description: "Manually renew a service for a specific period. COST WARNING: this charges the account balance / creates an invoice immediately, and renewal payments are NON-REFUNDABLE once confirmed. Verify the service and period with the user before calling. Payment object: { payment: 1, successUrl: '', cancelUrl: '' } for balance payment. Requires services:manage and a full-access API key.",
|
|
362
1771
|
inputSchema: {
|
|
363
1772
|
orderNo: z.string().describe("Order number"),
|
|
364
1773
|
period: z.number().describe("Period ID from get_period_options"),
|
|
@@ -383,12 +1792,11 @@ server.registerTool("renew_service", {
|
|
|
383
1792
|
});
|
|
384
1793
|
// --- Order New VPS ---
|
|
385
1794
|
server.registerTool("get_order_plans", {
|
|
386
|
-
description: "Get available plans for
|
|
1795
|
+
description: "Get available plans for one explicitly selected service product. Product facts are unordered: 'firecracker' is VPS using Firecracker microVMs for Linux; 'vds' is Cloud VPS (KVM) with High Availability, replicated Ceph NVMe storage, and Linux/Windows/BSD support; 'vps' is Container VPS for Linux; 'ds' is a dedicated single-tenant server. Select from the user's requirements and constraints rather than list position. Firecracker Functions is a separate usage-billed product and is not an orderable service type here.",
|
|
387
1796
|
inputSchema: {
|
|
388
1797
|
type: z
|
|
389
|
-
.enum(["vps"])
|
|
390
|
-
.
|
|
391
|
-
.describe("Service type (vps)"),
|
|
1798
|
+
.enum(["vps", "vds", "ds", "firecracker"])
|
|
1799
|
+
.describe("Required service product: firecracker (Linux VPS microVM), vds (Cloud VPS/KVM with Linux, Windows, or BSD), vps (Container VPS/Linux), or ds (dedicated server). Enum order is not a recommendation."),
|
|
392
1800
|
},
|
|
393
1801
|
}, async ({ type }) => {
|
|
394
1802
|
const { data } = await apiRequest("GET", `/order/configuration/${type}/plans`);
|
|
@@ -407,6 +1815,7 @@ server.registerTool("order_service", {
|
|
|
407
1815
|
description: [
|
|
408
1816
|
"Order a new VPS. Requires sufficient account balance for balance payment.",
|
|
409
1817
|
"Payment object for balance: { payment: 1, successUrl: '', cancelUrl: '' }.",
|
|
1818
|
+
"API-key orders first call the server quote endpoint, then confirm with the returned quoteToken. The API key must have paid scope/caps enabled.",
|
|
410
1819
|
"Resources: array of numeric resource value IDs from get_order_options, e.g. [901, 907].",
|
|
411
1820
|
"rootPassword: 6-40 chars, alphanumeric, must contain uppercase + lowercase + digit. Example: 'MyPass123'.",
|
|
412
1821
|
"sshKey and rootPassword are mutually exclusive — provide one or the other.",
|
|
@@ -427,6 +1836,7 @@ server.registerTool("order_service", {
|
|
|
427
1836
|
.array(z.number())
|
|
428
1837
|
.optional()
|
|
429
1838
|
.describe("Array of numeric resource value IDs from get_order_options, e.g. [901, 907, 902]"),
|
|
1839
|
+
idempotencyKey: idempotencyKeySchema.describe("Required for paid API-key orders. Stable unique key for this exact order attempt, e.g. UUID."),
|
|
430
1840
|
payment: z
|
|
431
1841
|
.object({
|
|
432
1842
|
payment: z
|
|
@@ -442,8 +1852,8 @@ server.registerTool("order_service", {
|
|
|
442
1852
|
.passthrough()
|
|
443
1853
|
.describe("Payment object. For balance: { payment: 1, successUrl: '', cancelUrl: '' }"),
|
|
444
1854
|
},
|
|
445
|
-
}, async ({ plan, os, rootPassword, sshKey, period, resources, payment }) => {
|
|
446
|
-
const body = { plan, payment };
|
|
1855
|
+
}, async ({ plan, os, rootPassword, sshKey, period, resources, idempotencyKey, payment }) => {
|
|
1856
|
+
const body = { plan, payment, idempotencyKey };
|
|
447
1857
|
if (os !== undefined)
|
|
448
1858
|
body.os = os;
|
|
449
1859
|
if (rootPassword)
|
|
@@ -454,12 +1864,18 @@ server.registerTool("order_service", {
|
|
|
454
1864
|
body.period = period;
|
|
455
1865
|
if (resources)
|
|
456
1866
|
body.resources = resources;
|
|
457
|
-
const { data } = await apiRequest("POST", "/order/configuration/
|
|
1867
|
+
const { data: quoteData } = await apiRequest("POST", "/order/configuration/quote", body, { "Idempotency-Key": idempotencyKey });
|
|
1868
|
+
const quoteToken = quoteData.quoteToken;
|
|
1869
|
+
if (!quoteToken) {
|
|
1870
|
+
throw new Error("Order quote did not return quoteToken");
|
|
1871
|
+
}
|
|
1872
|
+
body.quoteToken = quoteToken;
|
|
1873
|
+
const { data } = await apiRequest("POST", "/order/configuration/confirm", body, { "Idempotency-Key": idempotencyKey, "X-Quote-Token": quoteToken });
|
|
458
1874
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
459
1875
|
});
|
|
460
1876
|
// --- Backups ---
|
|
461
1877
|
server.registerTool("get_backup_status", {
|
|
462
|
-
description: "Get backup status and configuration for a service",
|
|
1878
|
+
description: "Get backup status and configuration for a service Requires services:read when called with an API key.",
|
|
463
1879
|
inputSchema: {
|
|
464
1880
|
orderNo: z.string().describe("Order number"),
|
|
465
1881
|
},
|
|
@@ -468,7 +1884,7 @@ server.registerTool("get_backup_status", {
|
|
|
468
1884
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
469
1885
|
});
|
|
470
1886
|
server.registerTool("get_backup_history", {
|
|
471
|
-
description: "Get backup history for a service",
|
|
1887
|
+
description: "Get backup history for a service Requires services:read when called with an API key.",
|
|
472
1888
|
inputSchema: {
|
|
473
1889
|
orderNo: z.string().describe("Order number"),
|
|
474
1890
|
},
|
|
@@ -477,7 +1893,7 @@ server.registerTool("get_backup_history", {
|
|
|
477
1893
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
478
1894
|
});
|
|
479
1895
|
server.registerTool("create_backup", {
|
|
480
|
-
description: "Create a new backup. Returns noty UUID for tracking. First call get_backup_status to see available period dates and price. Backup is a paid operation (price shown in get_backup_status).",
|
|
1896
|
+
description: "Create a new backup. Returns noty UUID for tracking. First call get_backup_status to see available period dates and price. Backup is a paid operation (price shown in get_backup_status). Requires services:manage and a full-access API key.",
|
|
481
1897
|
inputSchema: {
|
|
482
1898
|
orderNo: z.string().describe("Order number"),
|
|
483
1899
|
period: z
|
|
@@ -550,95 +1966,2156 @@ server.registerTool("delete_ssh_key", {
|
|
|
550
1966
|
});
|
|
551
1967
|
// --- API Keys ---
|
|
552
1968
|
server.registerTool("list_api_keys", {
|
|
553
|
-
description: "
|
|
1969
|
+
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.",
|
|
554
1970
|
inputSchema: {},
|
|
555
1971
|
}, async () => {
|
|
556
1972
|
const { data } = await apiRequest("GET", "/account/api-keys");
|
|
557
1973
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
558
1974
|
});
|
|
559
|
-
server.registerTool("
|
|
560
|
-
description: "
|
|
1975
|
+
server.registerTool("get_api_key", {
|
|
1976
|
+
description: "Get metadata for one active API key: scope, granular scopes, paid_scopes, paid_operations_enabled, daily_spend_limit_eur, monthly_spend_limit_eur, allowed IPs, expiry and rate_limit. For API-key callers only the calling key's own ID is accessible — any other id answers 403 apiKeyForbidden, even when it does not exist. The full key and stored secrets are never returned. Read-only.",
|
|
561
1977
|
inputSchema: {
|
|
562
|
-
|
|
563
|
-
allowed_ips: z
|
|
564
|
-
.string()
|
|
565
|
-
.optional()
|
|
566
|
-
.describe("Comma-separated allowed IPs"),
|
|
567
|
-
expires_at: z
|
|
568
|
-
.string()
|
|
569
|
-
.optional()
|
|
570
|
-
.describe("Expiry date (YYYY-MM-DD)"),
|
|
1978
|
+
id: z.number().int().positive().describe("API key ID from list_api_keys (the calling key's own ID)"),
|
|
571
1979
|
},
|
|
572
|
-
}, async ({
|
|
573
|
-
const
|
|
574
|
-
if (allowed_ips)
|
|
575
|
-
body.allowed_ips = allowed_ips;
|
|
576
|
-
if (expires_at)
|
|
577
|
-
body.expires_at = expires_at;
|
|
578
|
-
const { data } = await apiRequest("POST", "/account/api-keys", body);
|
|
1980
|
+
}, async ({ id }) => {
|
|
1981
|
+
const { data } = await apiRequest("GET", `/account/api-keys/${id}`);
|
|
579
1982
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
580
1983
|
});
|
|
581
|
-
server.registerTool("
|
|
582
|
-
description: "
|
|
1984
|
+
server.registerTool("get_api_key_activity", {
|
|
1985
|
+
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.",
|
|
583
1986
|
inputSchema: {
|
|
584
|
-
id: z.number().describe("API key ID"),
|
|
585
|
-
|
|
586
|
-
allowed_ips: z
|
|
587
|
-
.string()
|
|
588
|
-
.optional()
|
|
589
|
-
.describe("Comma-separated allowed IPs"),
|
|
590
|
-
expires_at: z
|
|
591
|
-
.string()
|
|
592
|
-
.optional()
|
|
593
|
-
.describe("Expiry date (YYYY-MM-DD)"),
|
|
1987
|
+
id: z.number().int().positive().describe("API key ID from list_api_keys (the calling key's own ID)"),
|
|
1988
|
+
limit: z.number().int().min(1).max(200).optional().describe("Recent request rows to return (default 50, maximum 200)"),
|
|
594
1989
|
},
|
|
595
|
-
}, async ({ id,
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
body.allowed_ips = allowed_ips;
|
|
599
|
-
if (expires_at)
|
|
600
|
-
body.expires_at = expires_at;
|
|
601
|
-
const { data } = await apiRequest("POST", `/account/api-keys/${id}`, body);
|
|
1990
|
+
}, async ({ id, limit }) => {
|
|
1991
|
+
const query = limit === undefined ? "" : `?limit=${encodeURIComponent(String(limit))}`;
|
|
1992
|
+
const { data } = await apiRequest("GET", `/account/api-keys/${id}/activity${query}`);
|
|
602
1993
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
603
1994
|
});
|
|
604
|
-
server.registerTool("
|
|
605
|
-
description: "
|
|
1995
|
+
server.registerTool("get_api_key_inference_usage", {
|
|
1996
|
+
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.",
|
|
606
1997
|
inputSchema: {
|
|
607
|
-
id: z.number().describe("API key ID"),
|
|
1998
|
+
id: z.number().int().positive().describe("Inference API key ID from list_api_keys (the calling key's own ID)"),
|
|
608
1999
|
},
|
|
609
2000
|
}, async ({ id }) => {
|
|
610
|
-
const { data } = await apiRequest("
|
|
2001
|
+
const { data } = await apiRequest("GET", `/account/api-keys/${id}/inference-usage`);
|
|
611
2002
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
612
2003
|
});
|
|
613
|
-
// ---
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
2004
|
+
// --- TLS certificates ---
|
|
2005
|
+
const certificatePaymentSchema = z
|
|
2006
|
+
.object({
|
|
2007
|
+
payment: z.number().int().positive().describe("Payment method ID; use 1 for account balance"),
|
|
2008
|
+
successUrl: z.string().max(2048).describe("Approved VPSnet redirect URL, or an empty string"),
|
|
2009
|
+
cancelUrl: z.string().max(2048).describe("Approved VPSnet redirect URL, or an empty string"),
|
|
2010
|
+
})
|
|
2011
|
+
.strict()
|
|
2012
|
+
.describe("Payment object. For account balance use { payment: 1, successUrl: '', cancelUrl: '' }");
|
|
2013
|
+
const certificateQuoteTokenSchema = z
|
|
2014
|
+
.string()
|
|
2015
|
+
.min(32)
|
|
2016
|
+
.max(190)
|
|
2017
|
+
.regex(/^[A-Za-z0-9._:-]{32,190}$/)
|
|
2018
|
+
.describe("Exact short-lived quote_token returned by the matching quote tool");
|
|
2019
|
+
const certificatePath = (suffix = "") => `/account/certificates${suffix}`;
|
|
2020
|
+
server.registerTool("get_free_certificate_eligibility", {
|
|
2021
|
+
description: "Check whether this account may request no-cost public DV certificates, its current anti-abuse quota, supported key-custody modes, and only the public certificate authorities that are operational in the current environment. This never exposes provider account IDs, credentials, or private keys. Requires certificates:read.",
|
|
2022
|
+
inputSchema: {},
|
|
2023
|
+
annotations: {
|
|
2024
|
+
title: "Check free-certificate eligibility",
|
|
2025
|
+
readOnlyHint: true,
|
|
2026
|
+
destructiveHint: false,
|
|
2027
|
+
idempotentHint: true,
|
|
618
2028
|
},
|
|
619
|
-
}, async (
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
2029
|
+
}, async () => {
|
|
2030
|
+
const { status, data } = await apiRequest("GET", certificatePath("/free/eligibility"));
|
|
2031
|
+
return {
|
|
2032
|
+
content: [{
|
|
2033
|
+
type: "text",
|
|
2034
|
+
text: formatJson(safeCertificatePayload("free-eligibility", status, data)),
|
|
2035
|
+
}],
|
|
2036
|
+
};
|
|
623
2037
|
});
|
|
624
|
-
server.registerTool("
|
|
625
|
-
description: "
|
|
2038
|
+
server.registerTool("preflight_free_certificate", {
|
|
2039
|
+
description: "Plan a no-cost public DV certificate without issuing it or changing DNS. Returns the canonical names and every currently available validation, delivery, key-custody, and CA choice. Call this before create_free_certificate and follow only options marked available. Requires certificates:read.",
|
|
2040
|
+
inputSchema: freeCertificatePreflightInputShape,
|
|
2041
|
+
annotations: {
|
|
2042
|
+
title: "Plan a free TLS certificate",
|
|
2043
|
+
readOnlyHint: true,
|
|
2044
|
+
destructiveHint: false,
|
|
2045
|
+
idempotentHint: true,
|
|
2046
|
+
},
|
|
2047
|
+
}, async (input) => {
|
|
2048
|
+
const body = freeCertificatePreflightRequestSchema.parse(input);
|
|
2049
|
+
const { status, data } = await apiRequest("POST", certificatePath("/free/preflight"), body);
|
|
2050
|
+
return {
|
|
2051
|
+
content: [{
|
|
2052
|
+
type: "text",
|
|
2053
|
+
text: formatJson(safeCertificatePayload("free-preflight", status, data)),
|
|
2054
|
+
}],
|
|
2055
|
+
};
|
|
2056
|
+
});
|
|
2057
|
+
server.registerTool("list_free_certificates", {
|
|
2058
|
+
description: "List customer-owned no-cost public-certificate requests and their safe lifecycle state. These are separate from paid portable certificates, paid Automatic SSL subscriptions, and application Managed HTTPS. Requires certificates:read.",
|
|
626
2059
|
inputSchema: {
|
|
627
|
-
|
|
2060
|
+
state: freeCertificateStateSchema.optional().describe("Optional exact lifecycle-state filter"),
|
|
628
2061
|
},
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
2062
|
+
annotations: {
|
|
2063
|
+
title: "List free TLS certificates",
|
|
2064
|
+
readOnlyHint: true,
|
|
2065
|
+
destructiveHint: false,
|
|
2066
|
+
idempotentHint: true,
|
|
2067
|
+
},
|
|
2068
|
+
}, async ({ state }) => {
|
|
2069
|
+
const query = state === undefined ? "" : `?state=${encodeURIComponent(state)}`;
|
|
2070
|
+
const { status, data } = await apiRequest("GET", certificatePath(`/free${query}`));
|
|
2071
|
+
return {
|
|
2072
|
+
content: [{
|
|
2073
|
+
type: "text",
|
|
2074
|
+
text: formatJson(safeCertificatePayload("free-list", status, data)),
|
|
2075
|
+
}],
|
|
2076
|
+
};
|
|
632
2077
|
});
|
|
633
|
-
server.registerTool("
|
|
634
|
-
description: "
|
|
2078
|
+
server.registerTool("get_free_certificate", {
|
|
2079
|
+
description: "Read one owned no-cost certificate request, including public CA, renewal schedule, validation state, safe timeline, and whether its public certificate is ready. A queued request is not proof of issuance. Requires certificates:read.",
|
|
2080
|
+
inputSchema: { free_certificate_request_id: freeCertificateRequestIdSchema },
|
|
2081
|
+
annotations: {
|
|
2082
|
+
title: "Get free TLS certificate",
|
|
2083
|
+
readOnlyHint: true,
|
|
2084
|
+
destructiveHint: false,
|
|
2085
|
+
idempotentHint: true,
|
|
2086
|
+
},
|
|
2087
|
+
}, async ({ free_certificate_request_id }) => {
|
|
2088
|
+
const { status, data } = await apiRequest("GET", certificatePath(`/free/${encodeURIComponent(free_certificate_request_id)}`));
|
|
2089
|
+
return {
|
|
2090
|
+
content: [{
|
|
2091
|
+
type: "text",
|
|
2092
|
+
text: formatJson(safeCertificatePayload("free-request", status, data)),
|
|
2093
|
+
}],
|
|
2094
|
+
};
|
|
2095
|
+
});
|
|
2096
|
+
server.registerTool("get_free_certificate_instruction", {
|
|
2097
|
+
description: "Read the current DNS validation instruction for one owned no-cost certificate request. VPSnet-hosted DNS may be automatic; external DNS returns every durable CNAME delegation the customer must create. No ACME challenge token or provider credential is returned. Requires certificates:read.",
|
|
2098
|
+
inputSchema: { free_certificate_request_id: freeCertificateRequestIdSchema },
|
|
2099
|
+
annotations: {
|
|
2100
|
+
title: "Get free-certificate validation instructions",
|
|
2101
|
+
readOnlyHint: true,
|
|
2102
|
+
destructiveHint: false,
|
|
2103
|
+
idempotentHint: true,
|
|
2104
|
+
},
|
|
2105
|
+
}, async ({ free_certificate_request_id }) => {
|
|
2106
|
+
const { status, data } = await apiRequest("GET", certificatePath(`/free/${encodeURIComponent(free_certificate_request_id)}/instruction`));
|
|
2107
|
+
return {
|
|
2108
|
+
content: [{
|
|
2109
|
+
type: "text",
|
|
2110
|
+
text: formatJson(safeCertificatePayload("free-instruction", status, data)),
|
|
2111
|
+
}],
|
|
2112
|
+
};
|
|
2113
|
+
});
|
|
2114
|
+
server.registerTool("create_free_certificate", {
|
|
2115
|
+
description: "Create one idempotent no-cost public DV certificate request after preflight. Call only after the user explicitly approves the exact DNS names, public CA, validation method, delivery method, and key-custody mode. For portable API/AI deployment use customer_csr and retain the private key on the destination. Managed mode permits unattended renewal, but its private key is portal-only behind two-factor verification and can never be retrieved through MCP. Requires certificates:manage.",
|
|
635
2116
|
inputSchema: {
|
|
636
|
-
|
|
2117
|
+
...freeCertificateCreateInputShape,
|
|
2118
|
+
acknowledge_public_certificate_request: z
|
|
2119
|
+
.literal(true)
|
|
2120
|
+
.describe("True only after the user approves this exact public-certificate request"),
|
|
2121
|
+
idempotencyKey: idempotencyKeySchema.describe("Stable key for this exact free-certificate request; reuse only for an unchanged retry"),
|
|
637
2122
|
},
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
2123
|
+
annotations: {
|
|
2124
|
+
title: "Create free TLS certificate",
|
|
2125
|
+
readOnlyHint: false,
|
|
2126
|
+
destructiveHint: false,
|
|
2127
|
+
idempotentHint: true,
|
|
2128
|
+
},
|
|
2129
|
+
}, async ({ acknowledge_public_certificate_request, idempotencyKey, ...input }) => {
|
|
2130
|
+
if (acknowledge_public_certificate_request !== true) {
|
|
2131
|
+
throw new Error("Explicit approval of the exact public-certificate request is required.");
|
|
2132
|
+
}
|
|
2133
|
+
const body = freeCertificateCreateRequestSchema.parse(input);
|
|
2134
|
+
const { status, data } = await apiRequest("POST", certificatePath("/free"), body, { "Idempotency-Key": idempotencyKey });
|
|
2135
|
+
return {
|
|
2136
|
+
content: [{
|
|
2137
|
+
type: "text",
|
|
2138
|
+
text: formatJson(safeCertificatePayload("free-create", status, data)),
|
|
2139
|
+
}],
|
|
2140
|
+
};
|
|
2141
|
+
});
|
|
2142
|
+
server.registerTool("download_free_certificate", {
|
|
2143
|
+
description: "Retrieve only the public leaf, issuer chain, and full chain for one issued no-cost certificate. It never decrypts, reconstructs, or returns a managed private key. A customer_csr result is deployable only together with the private key the customer retained when creating the CSR; managed private-key export remains portal-only with two-factor verification. Requires certificates:read.",
|
|
2144
|
+
inputSchema: { free_certificate_request_id: freeCertificateRequestIdSchema },
|
|
2145
|
+
annotations: {
|
|
2146
|
+
title: "Download public free-certificate bundle",
|
|
2147
|
+
readOnlyHint: true,
|
|
2148
|
+
destructiveHint: false,
|
|
2149
|
+
idempotentHint: true,
|
|
2150
|
+
},
|
|
2151
|
+
}, async ({ free_certificate_request_id }) => {
|
|
2152
|
+
const { status, data } = await apiRequest("GET", certificatePath(`/free/${encodeURIComponent(free_certificate_request_id)}/certificate`));
|
|
2153
|
+
return {
|
|
2154
|
+
content: [{
|
|
2155
|
+
type: "text",
|
|
2156
|
+
text: formatJson(safeCertificatePayload("free-artifact", status, data)),
|
|
2157
|
+
}],
|
|
2158
|
+
};
|
|
2159
|
+
});
|
|
2160
|
+
server.registerTool("manage_free_certificate", {
|
|
2161
|
+
description: "Request one free-certificate lifecycle action after reading its current state: early renew, revoke, recheck validation, or cancel before issuance. A customer_csr renewal requires a new public CSR for the unchanged authorized names; managed-key renewal normally happens automatically. Call only after explicit user approval. If the response is ambiguous, read get_free_certificate before deciding whether another action is needed. This tool cannot export a private key. Requires certificates:manage.",
|
|
2162
|
+
inputSchema: {
|
|
2163
|
+
free_certificate_request_id: freeCertificateRequestIdSchema,
|
|
2164
|
+
request: freeCertificateActionRequestSchema,
|
|
2165
|
+
acknowledge_free_certificate_action: z
|
|
2166
|
+
.literal(true)
|
|
2167
|
+
.describe("True only after the user approves this exact lifecycle action"),
|
|
2168
|
+
},
|
|
2169
|
+
annotations: {
|
|
2170
|
+
title: "Manage free TLS certificate",
|
|
2171
|
+
readOnlyHint: false,
|
|
2172
|
+
destructiveHint: true,
|
|
2173
|
+
idempotentHint: true,
|
|
2174
|
+
},
|
|
2175
|
+
}, async ({ free_certificate_request_id, request, acknowledge_free_certificate_action, }) => {
|
|
2176
|
+
if (acknowledge_free_certificate_action !== true) {
|
|
2177
|
+
throw new Error("Explicit free-certificate action approval is required.");
|
|
2178
|
+
}
|
|
2179
|
+
const { action, body } = freeCertificateActionRequestBody(request);
|
|
2180
|
+
const { status, data } = await apiRequest("POST", certificatePath(`/free/${encodeURIComponent(free_certificate_request_id)}/actions/${encodeURIComponent(action)}`), body);
|
|
2181
|
+
return {
|
|
2182
|
+
content: [{
|
|
2183
|
+
type: "text",
|
|
2184
|
+
text: formatJson(safeCertificatePayload("free-action", status, data)),
|
|
2185
|
+
}],
|
|
2186
|
+
};
|
|
2187
|
+
});
|
|
2188
|
+
server.registerTool("list_automatic_ssl_subscriptions", {
|
|
2189
|
+
description: "List customer-owned Automatic SSL subscriptions, subscribed DNS names, paid term, final EUR amount, public state, and whether portal-only setup credentials are ready. This never returns the private ACME server URL, account ID, EAB KID, or EAB HMAC key. Requires certificates:read.",
|
|
2190
|
+
inputSchema: {},
|
|
2191
|
+
annotations: {
|
|
2192
|
+
title: "List Automatic SSL subscriptions",
|
|
2193
|
+
readOnlyHint: true,
|
|
2194
|
+
destructiveHint: false,
|
|
2195
|
+
idempotentHint: true,
|
|
2196
|
+
},
|
|
2197
|
+
}, async () => {
|
|
2198
|
+
const { status, data } = await apiRequest("GET", certificatePath("/acme-subscriptions"));
|
|
2199
|
+
return {
|
|
2200
|
+
content: [{
|
|
2201
|
+
type: "text",
|
|
2202
|
+
text: formatJson(safeCertificatePayload("acme-subscription-list", status, data)),
|
|
2203
|
+
}],
|
|
2204
|
+
};
|
|
2205
|
+
});
|
|
2206
|
+
server.registerTool("get_automatic_ssl_subscription", {
|
|
2207
|
+
description: "Read one customer-owned Automatic SSL subscription. state=active and credentials_available=true means setup may continue in the two-factor-protected VPSnet portal; it does not expose credentials here. Requires certificates:read.",
|
|
2208
|
+
inputSchema: {
|
|
2209
|
+
certificate_subscription_id: certificateSubscriptionIdSchema,
|
|
2210
|
+
},
|
|
2211
|
+
annotations: {
|
|
2212
|
+
title: "Get Automatic SSL subscription",
|
|
2213
|
+
readOnlyHint: true,
|
|
2214
|
+
destructiveHint: false,
|
|
2215
|
+
idempotentHint: true,
|
|
2216
|
+
},
|
|
2217
|
+
}, async ({ certificate_subscription_id }) => {
|
|
2218
|
+
const { status, data } = await apiRequest("GET", certificatePath(`/acme-subscriptions/${encodeURIComponent(certificate_subscription_id)}`));
|
|
2219
|
+
return {
|
|
2220
|
+
content: [{
|
|
2221
|
+
type: "text",
|
|
2222
|
+
text: formatJson(safeCertificatePayload("acme-subscription", status, data)),
|
|
2223
|
+
}],
|
|
2224
|
+
};
|
|
2225
|
+
});
|
|
2226
|
+
server.registerTool("quote_automatic_ssl_subscription", {
|
|
2227
|
+
description: "Create a short-lived exact quote for Automatic SSL without charging. Use an acme_subscription catalog product and its exact current EUR offer generation. The subscribed names may be used by compatible ACME clients on VPSnet or another provider. A base name includes its www alias; apex and wildcard are separate identifiers. Disclose the final VAT-inclusive EUR total before ordering. Requires paid operations, certificates:order, spend caps, and a client-global idempotencyKey reused for confirmation.",
|
|
2228
|
+
inputSchema: {
|
|
2229
|
+
...certificateAcmeSubscriptionInputShape,
|
|
2230
|
+
idempotencyKey: paidIdempotencyKeySchema.describe("Client-global unique key used for this quote and its exact order confirmation"),
|
|
2231
|
+
},
|
|
2232
|
+
annotations: {
|
|
2233
|
+
title: "Quote Automatic SSL subscription",
|
|
2234
|
+
readOnlyHint: false,
|
|
2235
|
+
destructiveHint: false,
|
|
2236
|
+
idempotentHint: true,
|
|
2237
|
+
},
|
|
2238
|
+
}, async ({ idempotencyKey, ...input }) => {
|
|
2239
|
+
const { status, data } = await apiRequest("POST", certificatePath("/acme-subscriptions/quote"), input, { "Idempotency-Key": idempotencyKey });
|
|
2240
|
+
return {
|
|
2241
|
+
content: [{
|
|
2242
|
+
type: "text",
|
|
2243
|
+
text: formatJson(safeCertificatePayload("acme-subscription-quote", status, data)),
|
|
2244
|
+
}],
|
|
2245
|
+
};
|
|
2246
|
+
});
|
|
2247
|
+
server.registerTool("order_automatic_ssl_subscription", {
|
|
2248
|
+
description: "Confirm and pay for the exact Automatic SSL quote. Call only after the user approves the final EUR total, subscribed names, term, and payment method. Use the unchanged request, same idempotencyKey, and exact quote token. Setup credentials remain portal-only behind two-factor verification and are never returned to MCP. Requires certificates:order and paid-operation spend controls.",
|
|
2249
|
+
inputSchema: {
|
|
2250
|
+
...certificateAcmeSubscriptionInputShape,
|
|
2251
|
+
quote_token: z
|
|
2252
|
+
.string()
|
|
2253
|
+
.min(32)
|
|
2254
|
+
.max(190)
|
|
2255
|
+
.regex(/^[A-Za-z0-9._:-]{32,190}$/)
|
|
2256
|
+
.describe("Exact short-lived quote_token returned by quote_automatic_ssl_subscription"),
|
|
2257
|
+
payment: certificatePaymentSchema,
|
|
2258
|
+
acknowledge_exact_quote_and_payment: z
|
|
2259
|
+
.literal(true)
|
|
2260
|
+
.describe("True only after the user approves the exact EUR total, names, term, and payment"),
|
|
2261
|
+
idempotencyKey: paidIdempotencyKeySchema.describe("Same client-global idempotency key used for quote_automatic_ssl_subscription"),
|
|
2262
|
+
},
|
|
2263
|
+
annotations: {
|
|
2264
|
+
title: "Order Automatic SSL subscription",
|
|
2265
|
+
readOnlyHint: false,
|
|
2266
|
+
destructiveHint: true,
|
|
2267
|
+
idempotentHint: true,
|
|
2268
|
+
},
|
|
2269
|
+
}, async ({ quote_token, payment, acknowledge_exact_quote_and_payment, idempotencyKey, ...input }) => {
|
|
2270
|
+
if (acknowledge_exact_quote_and_payment !== true) {
|
|
2271
|
+
throw new Error("Explicit approval of the exact Automatic SSL quote and payment is required.");
|
|
2272
|
+
}
|
|
2273
|
+
const { status, data } = await apiRequest("POST", certificatePath("/acme-subscriptions/order"), { ...input, quoteToken: quote_token, payment }, {
|
|
2274
|
+
"Idempotency-Key": idempotencyKey,
|
|
2275
|
+
"X-Quote-Token": quote_token,
|
|
2276
|
+
});
|
|
2277
|
+
return {
|
|
2278
|
+
content: [{
|
|
2279
|
+
type: "text",
|
|
2280
|
+
text: formatJson(safeCertificatePayload("acme-subscription-confirmation", status, data)),
|
|
2281
|
+
}],
|
|
2282
|
+
};
|
|
2283
|
+
});
|
|
2284
|
+
server.registerTool("list_automatic_ssl_actions", {
|
|
2285
|
+
description: "List the latest customer-visible cancellation, domain addition, removal, and replacement actions for one owned Automatic SSL subscription. Certificate-authority payloads, credentials, commercial internals, and internal errors are omitted. Requires certificates:read.",
|
|
2286
|
+
inputSchema: {
|
|
2287
|
+
certificate_subscription_id: certificateSubscriptionIdSchema,
|
|
2288
|
+
},
|
|
2289
|
+
annotations: {
|
|
2290
|
+
title: "List Automatic SSL actions",
|
|
2291
|
+
readOnlyHint: true,
|
|
2292
|
+
destructiveHint: false,
|
|
2293
|
+
idempotentHint: true,
|
|
2294
|
+
},
|
|
2295
|
+
}, async ({ certificate_subscription_id }) => {
|
|
2296
|
+
const { status, data } = await apiRequest("GET", certificatePath(`/acme-subscriptions/${encodeURIComponent(certificate_subscription_id)}/actions`));
|
|
2297
|
+
return {
|
|
2298
|
+
content: [{
|
|
2299
|
+
type: "text",
|
|
2300
|
+
text: formatJson(safeCertificatePayload("acme-subscription-action-list", status, data)),
|
|
2301
|
+
}],
|
|
2302
|
+
};
|
|
2303
|
+
});
|
|
2304
|
+
server.registerTool("refresh_automatic_ssl_subscription", {
|
|
2305
|
+
description: "Durably schedule an authoritative read-only certificate-authority refresh for one owned Automatic SSL subscription. Use it for stale or ambiguous state; it never repeats a provider mutation. Poll get_automatic_ssl_subscription and list_automatic_ssl_actions afterward. Requires certificates:manage.",
|
|
2306
|
+
inputSchema: {
|
|
2307
|
+
certificate_subscription_id: certificateSubscriptionIdSchema,
|
|
2308
|
+
},
|
|
2309
|
+
annotations: {
|
|
2310
|
+
title: "Refresh Automatic SSL status",
|
|
2311
|
+
readOnlyHint: false,
|
|
2312
|
+
destructiveHint: false,
|
|
2313
|
+
idempotentHint: true,
|
|
2314
|
+
},
|
|
2315
|
+
}, async ({ certificate_subscription_id }) => {
|
|
2316
|
+
const { status, data } = await apiRequest("POST", certificatePath(`/acme-subscriptions/${encodeURIComponent(certificate_subscription_id)}/refresh`));
|
|
2317
|
+
return {
|
|
2318
|
+
content: [{
|
|
2319
|
+
type: "text",
|
|
2320
|
+
text: formatJson(safeCertificatePayload("acme-subscription-refresh", status, data)),
|
|
2321
|
+
}],
|
|
2322
|
+
};
|
|
2323
|
+
});
|
|
2324
|
+
server.registerTool("quote_automatic_ssl_domain", {
|
|
2325
|
+
description: "Quote one additional DNS name for an active Automatic SSL subscription without charging. A base name includes www and a wildcard includes its base name, so covered names cannot be purchased twice. Disclose the final VAT-inclusive EUR total before ordering. Requires paid operations, certificates:order, spend caps, and a client-global idempotencyKey reused for confirmation.",
|
|
2326
|
+
inputSchema: {
|
|
2327
|
+
certificate_subscription_id: certificateSubscriptionIdSchema,
|
|
2328
|
+
...certificateAcmeSubscriptionDomainInputShape,
|
|
2329
|
+
idempotencyKey: paidIdempotencyKeySchema.describe("Client-global unique key used for this domain quote and its exact confirmation"),
|
|
2330
|
+
},
|
|
2331
|
+
annotations: {
|
|
2332
|
+
title: "Quote Automatic SSL domain",
|
|
2333
|
+
readOnlyHint: false,
|
|
2334
|
+
destructiveHint: false,
|
|
2335
|
+
idempotentHint: true,
|
|
2336
|
+
},
|
|
2337
|
+
}, async ({ certificate_subscription_id, idempotencyKey, ...input }) => {
|
|
2338
|
+
const { status, data } = await apiRequest("POST", certificatePath(`/acme-subscriptions/${encodeURIComponent(certificate_subscription_id)}/domains/quote`), input, { "Idempotency-Key": idempotencyKey });
|
|
2339
|
+
return {
|
|
2340
|
+
content: [{
|
|
2341
|
+
type: "text",
|
|
2342
|
+
text: formatJson(safeCertificatePayload("acme-subscription-domain-quote", status, data)),
|
|
2343
|
+
}],
|
|
2344
|
+
};
|
|
2345
|
+
});
|
|
2346
|
+
server.registerTool("order_automatic_ssl_domain", {
|
|
2347
|
+
description: "Confirm and pay for the exact additional Automatic SSL DNS-name quote. Call only after the user approves the final EUR total, DNS name, and payment method. Use the unchanged name, same idempotencyKey, and exact quote token. Requires certificates:order and paid-operation spend controls.",
|
|
2348
|
+
inputSchema: {
|
|
2349
|
+
certificate_subscription_id: certificateSubscriptionIdSchema,
|
|
2350
|
+
...certificateAcmeSubscriptionDomainInputShape,
|
|
2351
|
+
quote_token: certificateQuoteTokenSchema,
|
|
2352
|
+
payment: certificatePaymentSchema,
|
|
2353
|
+
acknowledge_exact_quote_and_payment: z
|
|
2354
|
+
.literal(true)
|
|
2355
|
+
.describe("True only after the user approves the exact EUR total, DNS name, and payment"),
|
|
2356
|
+
idempotencyKey: paidIdempotencyKeySchema.describe("Same client-global idempotency key used for quote_automatic_ssl_domain"),
|
|
2357
|
+
},
|
|
2358
|
+
annotations: {
|
|
2359
|
+
title: "Order Automatic SSL domain",
|
|
2360
|
+
readOnlyHint: false,
|
|
2361
|
+
destructiveHint: true,
|
|
2362
|
+
idempotentHint: true,
|
|
2363
|
+
},
|
|
2364
|
+
}, async ({ certificate_subscription_id, quote_token, payment, acknowledge_exact_quote_and_payment, idempotencyKey, ...input }) => {
|
|
2365
|
+
if (acknowledge_exact_quote_and_payment !== true) {
|
|
2366
|
+
throw new Error("Explicit approval of the exact Automatic SSL domain quote and payment is required.");
|
|
2367
|
+
}
|
|
2368
|
+
const { status, data } = await apiRequest("POST", certificatePath(`/acme-subscriptions/${encodeURIComponent(certificate_subscription_id)}/domains/order`), { ...input, quoteToken: quote_token, payment }, {
|
|
2369
|
+
"Idempotency-Key": idempotencyKey,
|
|
2370
|
+
"X-Quote-Token": quote_token,
|
|
2371
|
+
});
|
|
2372
|
+
return {
|
|
2373
|
+
content: [{
|
|
2374
|
+
type: "text",
|
|
2375
|
+
text: formatJson(safeCertificatePayload("acme-subscription-domain-confirmation", status, data)),
|
|
2376
|
+
}],
|
|
2377
|
+
};
|
|
2378
|
+
});
|
|
2379
|
+
server.registerTool("quote_automatic_ssl_renewal", {
|
|
2380
|
+
description: "Quote the next term of an owned renewable Automatic SSL subscription during its final 30 days without charging. The quote is bound to the exact current DNS-name set, renewal date, offer generation, and final VAT-inclusive EUR total. Requires paid operations, certificates:order, spend caps, and a client-global idempotencyKey reused for confirmation.",
|
|
2381
|
+
inputSchema: {
|
|
2382
|
+
certificate_subscription_id: certificateSubscriptionIdSchema,
|
|
2383
|
+
idempotencyKey: paidIdempotencyKeySchema.describe("Client-global unique key used for this renewal quote and its exact confirmation"),
|
|
2384
|
+
},
|
|
2385
|
+
annotations: {
|
|
2386
|
+
title: "Quote Automatic SSL renewal",
|
|
2387
|
+
readOnlyHint: false,
|
|
2388
|
+
destructiveHint: false,
|
|
2389
|
+
idempotentHint: true,
|
|
2390
|
+
},
|
|
2391
|
+
}, async ({ certificate_subscription_id, idempotencyKey }) => {
|
|
2392
|
+
const { status, data } = await apiRequest("POST", certificatePath(`/acme-subscriptions/${encodeURIComponent(certificate_subscription_id)}/renewal/quote`), {}, { "Idempotency-Key": idempotencyKey });
|
|
2393
|
+
return {
|
|
2394
|
+
content: [{
|
|
2395
|
+
type: "text",
|
|
2396
|
+
text: formatJson(safeCertificatePayload("acme-subscription-renewal-quote", status, data)),
|
|
2397
|
+
}],
|
|
2398
|
+
};
|
|
2399
|
+
});
|
|
2400
|
+
server.registerTool("order_automatic_ssl_renewal", {
|
|
2401
|
+
description: "Prepay one exact next Automatic SSL term from account balance. Call only after the user approves the renewal date, DNS-name count, and final VAT-inclusive EUR total. Use the same idempotencyKey and exact quote token. A definitive provider failure is credited once to balance; an uncertain result remains under reconciliation and must not be submitted again with a new key. Requires certificates:order and paid-operation spend controls.",
|
|
2402
|
+
inputSchema: {
|
|
2403
|
+
certificate_subscription_id: certificateSubscriptionIdSchema,
|
|
2404
|
+
quote_token: certificateQuoteTokenSchema,
|
|
2405
|
+
acknowledge_exact_quote_and_balance_payment: z
|
|
2406
|
+
.literal(true)
|
|
2407
|
+
.describe("True only after the user approves the exact renewal and balance charge"),
|
|
2408
|
+
idempotencyKey: paidIdempotencyKeySchema.describe("Same client-global idempotency key used for quote_automatic_ssl_renewal"),
|
|
2409
|
+
},
|
|
2410
|
+
annotations: {
|
|
2411
|
+
title: "Prepay Automatic SSL renewal",
|
|
2412
|
+
readOnlyHint: false,
|
|
2413
|
+
destructiveHint: true,
|
|
2414
|
+
idempotentHint: true,
|
|
2415
|
+
},
|
|
2416
|
+
}, async ({ certificate_subscription_id, quote_token, acknowledge_exact_quote_and_balance_payment, idempotencyKey, }) => {
|
|
2417
|
+
if (acknowledge_exact_quote_and_balance_payment !== true) {
|
|
2418
|
+
throw new Error("Explicit approval of the exact Automatic SSL renewal and balance charge is required.");
|
|
2419
|
+
}
|
|
2420
|
+
const { status, data } = await apiRequest("POST", certificatePath(`/acme-subscriptions/${encodeURIComponent(certificate_subscription_id)}/renewal/order`), { quoteToken: quote_token }, {
|
|
2421
|
+
"Idempotency-Key": idempotencyKey,
|
|
2422
|
+
"X-Quote-Token": quote_token,
|
|
2423
|
+
});
|
|
2424
|
+
return {
|
|
2425
|
+
content: [{
|
|
2426
|
+
type: "text",
|
|
2427
|
+
text: formatJson(safeCertificatePayload("acme-subscription-renewal-confirmation", status, data)),
|
|
2428
|
+
}],
|
|
2429
|
+
};
|
|
2430
|
+
});
|
|
2431
|
+
server.registerTool("manage_automatic_ssl_subscription", {
|
|
2432
|
+
description: "Queue one exact idempotent Automatic SSL action: cancel the subscription, remove an eligible purchased DNS name, or replace an eligible purchased DNS name during its correction window. Read the subscription and its actions first, and call only after explicit approval. If the result becomes ambiguous, reuse the same key only for an unchanged retry or call refresh_automatic_ssl_subscription—never submit a new mutation key. Requires certificates:manage.",
|
|
2433
|
+
inputSchema: {
|
|
2434
|
+
certificate_subscription_id: certificateSubscriptionIdSchema,
|
|
2435
|
+
request: certificateAcmeSubscriptionActionRequestSchema,
|
|
2436
|
+
acknowledge_automatic_ssl_action: z
|
|
2437
|
+
.literal(true)
|
|
2438
|
+
.describe("True only after the user approves this exact Automatic SSL action"),
|
|
2439
|
+
idempotencyKey: paidIdempotencyKeySchema.describe("Client-global key for one exact action. Reuse only for an unchanged retry."),
|
|
2440
|
+
},
|
|
2441
|
+
annotations: {
|
|
2442
|
+
title: "Manage Automatic SSL subscription",
|
|
2443
|
+
readOnlyHint: false,
|
|
2444
|
+
destructiveHint: true,
|
|
2445
|
+
idempotentHint: true,
|
|
2446
|
+
},
|
|
2447
|
+
}, async ({ certificate_subscription_id, request, acknowledge_automatic_ssl_action, idempotencyKey, }) => {
|
|
2448
|
+
if (acknowledge_automatic_ssl_action !== true) {
|
|
2449
|
+
throw new Error("Explicit Automatic SSL action approval is required.");
|
|
2450
|
+
}
|
|
2451
|
+
const { action, body } = certificateAcmeSubscriptionActionRequestBody(request);
|
|
2452
|
+
const { status, data } = await apiRequest("POST", certificatePath(`/acme-subscriptions/${encodeURIComponent(certificate_subscription_id)}/actions/${encodeURIComponent(action)}`), body, { "Idempotency-Key": idempotencyKey });
|
|
2453
|
+
return {
|
|
2454
|
+
content: [{
|
|
2455
|
+
type: "text",
|
|
2456
|
+
text: formatJson(safeCertificatePayload("acme-subscription-action", status, data)),
|
|
2457
|
+
}],
|
|
2458
|
+
};
|
|
2459
|
+
});
|
|
2460
|
+
server.registerTool("list_certificate_catalog", {
|
|
2461
|
+
description: "List published paid TLS certificate products and final customer offers in EUR. Products disclose validation level, public certificate-authority brand, supported names, SAN limits, management capabilities, term, and final retail prices. Only customer-visible catalog data is returned. Paid certificates are portable and are not limited to managed applications. Requires certificates:read.",
|
|
2462
|
+
inputSchema: {},
|
|
2463
|
+
annotations: {
|
|
2464
|
+
title: "List paid TLS certificate catalog",
|
|
2465
|
+
readOnlyHint: true,
|
|
2466
|
+
destructiveHint: false,
|
|
2467
|
+
idempotentHint: true,
|
|
2468
|
+
},
|
|
2469
|
+
}, async () => {
|
|
2470
|
+
const { status, data } = await apiRequest("GET", certificatePath("/catalog"));
|
|
2471
|
+
return {
|
|
2472
|
+
content: [{
|
|
2473
|
+
type: "text",
|
|
2474
|
+
text: formatJson(safeCertificatePayload("catalog-list", status, data)),
|
|
2475
|
+
}],
|
|
2476
|
+
};
|
|
2477
|
+
});
|
|
2478
|
+
server.registerTool("get_certificate_catalog_product", {
|
|
2479
|
+
description: "Read one published paid TLS product with final EUR offers and customer-visible capabilities. Use the exact offer ID and generation from this response when quoting. Requires certificates:read.",
|
|
2480
|
+
inputSchema: {
|
|
2481
|
+
product_id: certificateProductIdSchema,
|
|
2482
|
+
},
|
|
2483
|
+
annotations: {
|
|
2484
|
+
title: "Get paid TLS certificate product",
|
|
2485
|
+
readOnlyHint: true,
|
|
2486
|
+
destructiveHint: false,
|
|
2487
|
+
idempotentHint: true,
|
|
2488
|
+
},
|
|
2489
|
+
}, async ({ product_id }) => {
|
|
2490
|
+
const { status, data } = await apiRequest("GET", certificatePath(`/catalog/${encodeURIComponent(String(product_id))}`));
|
|
2491
|
+
return {
|
|
2492
|
+
content: [{
|
|
2493
|
+
type: "text",
|
|
2494
|
+
text: formatJson(safeCertificatePayload("catalog-product", status, data)),
|
|
2495
|
+
}],
|
|
2496
|
+
};
|
|
2497
|
+
});
|
|
2498
|
+
server.registerTool("list_certificates", {
|
|
2499
|
+
description: "List customer-owned paid TLS certificate orders, final EUR amount, validation and issuance state, renewal dates, and whether the public certificate bundle is ready. attention_required is not success. Requires certificates:read.",
|
|
2500
|
+
inputSchema: {},
|
|
2501
|
+
annotations: {
|
|
2502
|
+
title: "List paid TLS certificates",
|
|
2503
|
+
readOnlyHint: true,
|
|
2504
|
+
destructiveHint: false,
|
|
2505
|
+
idempotentHint: true,
|
|
2506
|
+
},
|
|
2507
|
+
}, async () => {
|
|
2508
|
+
const { status, data } = await apiRequest("GET", certificatePath());
|
|
2509
|
+
return {
|
|
2510
|
+
content: [{
|
|
2511
|
+
type: "text",
|
|
2512
|
+
text: formatJson(safeCertificatePayload("order-list", status, data)),
|
|
2513
|
+
}],
|
|
2514
|
+
};
|
|
2515
|
+
});
|
|
2516
|
+
server.registerTool("get_certificate", {
|
|
2517
|
+
description: "Read one customer-owned paid TLS certificate order. A paid, queued, or validating order is not issued; require state=active and download_available=true before treating the public certificate as ready. Requires certificates:read.",
|
|
2518
|
+
inputSchema: {
|
|
2519
|
+
certificate_order_id: certificateOrderIdSchema,
|
|
2520
|
+
},
|
|
2521
|
+
annotations: {
|
|
2522
|
+
title: "Get paid TLS certificate",
|
|
2523
|
+
readOnlyHint: true,
|
|
2524
|
+
destructiveHint: false,
|
|
2525
|
+
idempotentHint: true,
|
|
2526
|
+
},
|
|
2527
|
+
}, async ({ certificate_order_id }) => {
|
|
2528
|
+
const { status, data } = await apiRequest("GET", certificatePath(`/${encodeURIComponent(certificate_order_id)}`));
|
|
2529
|
+
return {
|
|
2530
|
+
content: [{
|
|
2531
|
+
type: "text",
|
|
2532
|
+
text: formatJson(safeCertificatePayload("order", status, data)),
|
|
2533
|
+
}],
|
|
2534
|
+
};
|
|
2535
|
+
});
|
|
2536
|
+
server.registerTool("quote_certificate", {
|
|
2537
|
+
description: "Create a short-lived quote for one exact paid TLS certificate or eligible renewal. This validates the current catalog generation, identifiers, customer contacts, and the public PKCS#10 CSR without charging or ordering. The response contains the final customer total in EUR including VAT. Disclose that exact total before order_certificate. The customer private key must never be supplied. Requires paid operations, certificates:order, spend caps, and a client-global idempotencyKey that must also be used for confirmation.",
|
|
2538
|
+
inputSchema: {
|
|
2539
|
+
...certificateOrderInputShape,
|
|
2540
|
+
idempotencyKey: paidIdempotencyKeySchema.describe("Client-global unique key used for this quote and its exact order confirmation"),
|
|
2541
|
+
},
|
|
2542
|
+
annotations: {
|
|
2543
|
+
title: "Quote paid TLS certificate",
|
|
2544
|
+
readOnlyHint: false,
|
|
2545
|
+
destructiveHint: false,
|
|
2546
|
+
idempotentHint: true,
|
|
2547
|
+
},
|
|
2548
|
+
}, async ({ idempotencyKey, ...input }) => {
|
|
2549
|
+
const { status, data } = await apiRequest("POST", certificatePath("/quote"), input, { "Idempotency-Key": idempotencyKey });
|
|
2550
|
+
return {
|
|
2551
|
+
content: [{
|
|
2552
|
+
type: "text",
|
|
2553
|
+
text: formatJson(safeCertificatePayload("quote", status, data)),
|
|
2554
|
+
}],
|
|
2555
|
+
};
|
|
2556
|
+
});
|
|
2557
|
+
server.registerTool("order_certificate", {
|
|
2558
|
+
description: "Confirm and pay for the exact quote returned by quote_certificate. Call only after the user explicitly approves the exact final EUR total, certificate names, term, validation level, and payment method. Use the unchanged request, same idempotencyKey, and exact short-lived quote token. The private key remains customer-controlled. Paid certificate orders may become non-refundable once submitted or issued under the certificate authority's policy. Requires certificates:order and paid-operation spend controls.",
|
|
2559
|
+
inputSchema: {
|
|
2560
|
+
...certificateOrderInputShape,
|
|
2561
|
+
quote_token: z
|
|
2562
|
+
.string()
|
|
2563
|
+
.min(32)
|
|
2564
|
+
.max(190)
|
|
2565
|
+
.regex(/^[A-Za-z0-9._:-]{32,190}$/)
|
|
2566
|
+
.describe("Exact short-lived quote_token returned by quote_certificate"),
|
|
2567
|
+
payment: certificatePaymentSchema,
|
|
2568
|
+
acknowledge_exact_quote_and_payment: z
|
|
2569
|
+
.literal(true)
|
|
2570
|
+
.describe("True only after the user approves the exact quoted EUR total, names, term, and payment"),
|
|
2571
|
+
idempotencyKey: paidIdempotencyKeySchema.describe("Same client-global idempotency key used for quote_certificate"),
|
|
2572
|
+
},
|
|
2573
|
+
annotations: {
|
|
2574
|
+
title: "Order paid TLS certificate",
|
|
2575
|
+
readOnlyHint: false,
|
|
2576
|
+
destructiveHint: true,
|
|
2577
|
+
idempotentHint: true,
|
|
2578
|
+
},
|
|
2579
|
+
}, async ({ quote_token, payment, acknowledge_exact_quote_and_payment, idempotencyKey, ...input }) => {
|
|
2580
|
+
if (acknowledge_exact_quote_and_payment !== true) {
|
|
2581
|
+
throw new Error("Explicit approval of the exact certificate quote and payment is required.");
|
|
2582
|
+
}
|
|
2583
|
+
const { status, data } = await apiRequest("POST", certificatePath("/order"), { ...input, quoteToken: quote_token, payment }, {
|
|
2584
|
+
"Idempotency-Key": idempotencyKey,
|
|
2585
|
+
"X-Quote-Token": quote_token,
|
|
2586
|
+
});
|
|
2587
|
+
return {
|
|
2588
|
+
content: [{
|
|
2589
|
+
type: "text",
|
|
2590
|
+
text: formatJson(safeCertificatePayload("confirmation", status, data)),
|
|
2591
|
+
}],
|
|
2592
|
+
};
|
|
2593
|
+
});
|
|
2594
|
+
server.registerTool("get_certificate_validation", {
|
|
2595
|
+
description: "Read current per-name domain-control validation state and the owner-visible challenge while it is pending. Wildcards require DNS validation. This never submits or changes a validation method. Requires certificates:read.",
|
|
2596
|
+
inputSchema: {
|
|
2597
|
+
certificate_order_id: certificateOrderIdSchema,
|
|
2598
|
+
},
|
|
2599
|
+
annotations: {
|
|
2600
|
+
title: "Get certificate validation",
|
|
2601
|
+
readOnlyHint: true,
|
|
2602
|
+
destructiveHint: false,
|
|
2603
|
+
idempotentHint: true,
|
|
2604
|
+
},
|
|
2605
|
+
}, async ({ certificate_order_id }) => {
|
|
2606
|
+
const { status, data } = await apiRequest("GET", certificatePath(`/${encodeURIComponent(certificate_order_id)}/validation`));
|
|
2607
|
+
return {
|
|
2608
|
+
content: [{
|
|
2609
|
+
type: "text",
|
|
2610
|
+
text: formatJson(safeCertificatePayload("validation", status, data)),
|
|
2611
|
+
}],
|
|
2612
|
+
};
|
|
2613
|
+
});
|
|
2614
|
+
server.registerTool("download_certificate", {
|
|
2615
|
+
description: "Download the issued public leaf, chain, and full chain for one owned order after download_available becomes true. The bundle is portable to Nginx, Apache, lighttpd, OpenLiteSpeed, HAProxy, Caddy, mail, API, and load-balancer endpoints. It never includes or reconstructs the customer private key. Requires certificates:read.",
|
|
2616
|
+
inputSchema: {
|
|
2617
|
+
certificate_order_id: certificateOrderIdSchema,
|
|
2618
|
+
},
|
|
2619
|
+
annotations: {
|
|
2620
|
+
title: "Download public TLS certificate bundle",
|
|
2621
|
+
readOnlyHint: true,
|
|
2622
|
+
destructiveHint: false,
|
|
2623
|
+
idempotentHint: true,
|
|
2624
|
+
},
|
|
2625
|
+
}, async ({ certificate_order_id }) => {
|
|
2626
|
+
const { status, data } = await apiRequest("GET", certificatePath(`/${encodeURIComponent(certificate_order_id)}/download`));
|
|
2627
|
+
return {
|
|
2628
|
+
content: [{
|
|
2629
|
+
type: "text",
|
|
2630
|
+
text: formatJson(safeCertificatePayload("artifact", status, data)),
|
|
2631
|
+
}],
|
|
2632
|
+
};
|
|
2633
|
+
});
|
|
2634
|
+
server.registerTool("list_certificate_actions", {
|
|
2635
|
+
description: "List durable customer-visible management actions for one paid certificate. Raw certificate-authority responses, request bodies, internal errors, provider IDs, and credentials are never returned. Requires certificates:read.",
|
|
2636
|
+
inputSchema: {
|
|
2637
|
+
certificate_order_id: certificateOrderIdSchema,
|
|
2638
|
+
},
|
|
2639
|
+
annotations: {
|
|
2640
|
+
title: "List certificate actions",
|
|
2641
|
+
readOnlyHint: true,
|
|
2642
|
+
destructiveHint: false,
|
|
2643
|
+
idempotentHint: true,
|
|
2644
|
+
},
|
|
2645
|
+
}, async ({ certificate_order_id }) => {
|
|
2646
|
+
const { status, data } = await apiRequest("GET", certificatePath(`/${encodeURIComponent(certificate_order_id)}/actions`));
|
|
2647
|
+
return {
|
|
2648
|
+
content: [{
|
|
2649
|
+
type: "text",
|
|
2650
|
+
text: formatJson(safeCertificatePayload("action-list", status, data)),
|
|
2651
|
+
}],
|
|
2652
|
+
};
|
|
2653
|
+
});
|
|
2654
|
+
server.registerTool("refresh_certificate", {
|
|
2655
|
+
description: "Durably schedule a read-only certificate-authority status reconciliation for one owned order. Use this for stale or ambiguous state; it never repeats an order or management mutation. Poll get_certificate and list_certificate_actions afterward. Requires certificates:manage.",
|
|
2656
|
+
inputSchema: {
|
|
2657
|
+
certificate_order_id: certificateOrderIdSchema,
|
|
2658
|
+
},
|
|
2659
|
+
annotations: {
|
|
2660
|
+
title: "Refresh certificate status",
|
|
2661
|
+
readOnlyHint: false,
|
|
2662
|
+
destructiveHint: false,
|
|
2663
|
+
idempotentHint: true,
|
|
2664
|
+
},
|
|
2665
|
+
}, async ({ certificate_order_id }) => {
|
|
2666
|
+
const { status, data } = await apiRequest("POST", certificatePath(`/${encodeURIComponent(certificate_order_id)}/refresh`));
|
|
2667
|
+
return {
|
|
2668
|
+
content: [{
|
|
2669
|
+
type: "text",
|
|
2670
|
+
text: formatJson(safeCertificatePayload("refresh", status, data)),
|
|
2671
|
+
}],
|
|
2672
|
+
};
|
|
2673
|
+
});
|
|
2674
|
+
server.registerTool("manage_certificate", {
|
|
2675
|
+
description: "Queue one exact idempotent paid-certificate management action: cancel an eligible pending order, recheck or resend validation, change one validation method, or request a free same-name reissue with a new public CSR. Read the product capabilities, current order, validation, and actions first. Call only after explicit approval. A reissue must preserve the paid name set and never sends a private key. If the outcome becomes ambiguous, reuse the same key only for the exact retry or call refresh_certificate—never submit a new mutation key. Requires certificates:manage.",
|
|
2676
|
+
inputSchema: {
|
|
2677
|
+
certificate_order_id: certificateOrderIdSchema,
|
|
2678
|
+
request: certificateActionRequestSchema,
|
|
2679
|
+
acknowledge_certificate_action: z
|
|
2680
|
+
.literal(true)
|
|
2681
|
+
.describe("True only after the user approves this exact certificate management action"),
|
|
2682
|
+
idempotencyKey: paidIdempotencyKeySchema.describe("Client-global key for one exact action. Reuse only for an unchanged retry."),
|
|
2683
|
+
},
|
|
2684
|
+
annotations: {
|
|
2685
|
+
title: "Manage paid TLS certificate",
|
|
2686
|
+
readOnlyHint: false,
|
|
2687
|
+
destructiveHint: true,
|
|
2688
|
+
idempotentHint: true,
|
|
2689
|
+
},
|
|
2690
|
+
}, async ({ certificate_order_id, request, acknowledge_certificate_action, idempotencyKey, }) => {
|
|
2691
|
+
if (acknowledge_certificate_action !== true) {
|
|
2692
|
+
throw new Error("Explicit certificate-action approval is required.");
|
|
2693
|
+
}
|
|
2694
|
+
const { action, body } = certificateActionRequestBody(request);
|
|
2695
|
+
const { status, data } = await apiRequest("POST", certificatePath(`/${encodeURIComponent(certificate_order_id)}/actions/${encodeURIComponent(action)}`), body, { "Idempotency-Key": idempotencyKey });
|
|
2696
|
+
return {
|
|
2697
|
+
content: [{
|
|
2698
|
+
type: "text",
|
|
2699
|
+
text: formatJson(safeCertificatePayload("action", status, data)),
|
|
2700
|
+
}],
|
|
2701
|
+
};
|
|
2702
|
+
});
|
|
2703
|
+
// --- DNS ---
|
|
2704
|
+
server.registerTool("list_domains", {
|
|
2705
|
+
description: "List domains owned by the account, including status, expiry, nameservers, DNS zone link, and renewal settings. Requires domains:read when using an API key.",
|
|
2706
|
+
inputSchema: {},
|
|
2707
|
+
}, async () => {
|
|
2708
|
+
const { data } = await apiRequest("GET", "/account/domains");
|
|
2709
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2710
|
+
});
|
|
2711
|
+
server.registerTool("get_domain", {
|
|
2712
|
+
description: "Get one owned domain with current nameservers and any pending domain action. Requires domains:read when using an API key.",
|
|
2713
|
+
inputSchema: {
|
|
2714
|
+
domain_id: z.number().describe("Owned domain ID from list_domains"),
|
|
2715
|
+
},
|
|
2716
|
+
}, async ({ domain_id }) => {
|
|
2717
|
+
const { data } = await apiRequest("GET", `/account/domains/${domain_id}`);
|
|
2718
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2719
|
+
});
|
|
2720
|
+
server.registerTool("list_domain_tlds", {
|
|
2721
|
+
description: "List TLDs currently enabled for the VPSNet domain catalog. Requires domains:read when using an API key.",
|
|
2722
|
+
inputSchema: {},
|
|
2723
|
+
}, async () => {
|
|
2724
|
+
const { data } = await apiRequest("GET", "/account/domains/tlds");
|
|
2725
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2726
|
+
});
|
|
2727
|
+
server.registerTool("set_domain_nameservers", {
|
|
2728
|
+
description: "Queue an asynchronous nameserver change for an owned domain. 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.",
|
|
2729
|
+
inputSchema: {
|
|
2730
|
+
domain_id: z.number().describe("Owned domain ID from list_domains"),
|
|
2731
|
+
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."),
|
|
2732
|
+
idempotencyKey: idempotencyKeySchema.optional().describe("Optional idempotency key for the queued domain action"),
|
|
2733
|
+
},
|
|
2734
|
+
}, async ({ domain_id, nameservers, idempotencyKey }) => {
|
|
2735
|
+
const headers = idempotencyKey ? { "Idempotency-Key": idempotencyKey } : undefined;
|
|
2736
|
+
const { data } = await apiRequest("POST", `/account/domains/${domain_id}/nameservers`, {
|
|
2737
|
+
nameservers,
|
|
2738
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
2739
|
+
}, headers);
|
|
2740
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2741
|
+
});
|
|
2742
|
+
server.registerTool("list_domain_glue_records", {
|
|
2743
|
+
description: "List same-domain nameserver IP records for an owned domain, e.g. ns1.example.com -> 203.0.113.10. Requires domains:read when using an API key.",
|
|
2744
|
+
inputSchema: {
|
|
2745
|
+
domain_id: z.number().describe("Owned domain ID from list_domains"),
|
|
2746
|
+
},
|
|
2747
|
+
}, async ({ domain_id }) => {
|
|
2748
|
+
const { data } = await apiRequest("GET", `/account/domains/${domain_id}/glue`);
|
|
2749
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2750
|
+
});
|
|
2751
|
+
server.registerTool("create_domain_glue_record", {
|
|
2752
|
+
description: "Queue creating or updating a same-domain nameserver IP record for an owned domain. Hostname must be below the domain, addresses must be public IPs, and this does not create DNS A/AAAA records or PTR. Requires domains:manage when using an API key.",
|
|
2753
|
+
inputSchema: {
|
|
2754
|
+
domain_id: z.number().describe("Owned domain ID from list_domains"),
|
|
2755
|
+
hostname: glueHostnameSchema,
|
|
2756
|
+
addresses: z.array(gluePublicIpSchema).min(1).max(13).describe("Public IPv4/IPv6 addresses for this nameserver host"),
|
|
2757
|
+
idempotencyKey: idempotencyKeySchema.describe("Unique key for this queued domain action; sent as Idempotency-Key"),
|
|
2758
|
+
},
|
|
2759
|
+
}, async ({ domain_id, hostname, addresses, idempotencyKey }) => {
|
|
2760
|
+
const { data } = await apiRequest("POST", `/account/domains/${domain_id}/glue`, { hostname, addresses, idempotencyKey }, { "Idempotency-Key": idempotencyKey });
|
|
2761
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2762
|
+
});
|
|
2763
|
+
server.registerTool("delete_domain_glue_record", {
|
|
2764
|
+
description: "Queue deleting a same-domain nameserver IP record for an owned domain. Remove or change domain nameserver delegation first if the hostname is still delegated. Requires domains:manage when using an API key.",
|
|
2765
|
+
inputSchema: {
|
|
2766
|
+
domain_id: z.number().describe("Owned domain ID from list_domains"),
|
|
2767
|
+
record_id: z.number().describe("Record ID from list_domain_glue_records"),
|
|
2768
|
+
idempotencyKey: idempotencyKeySchema.describe("Unique key for this queued domain action; sent as Idempotency-Key"),
|
|
2769
|
+
},
|
|
2770
|
+
}, async ({ domain_id, record_id, idempotencyKey }) => {
|
|
2771
|
+
const { data } = await apiRequest("DELETE", `/account/domains/${domain_id}/glue/${record_id}`, { idempotencyKey }, { "Idempotency-Key": idempotencyKey });
|
|
2772
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2773
|
+
});
|
|
2774
|
+
server.registerTool("get_domain_parent_ds", {
|
|
2775
|
+
description: "List parent-zone DNSSEC DS records for an owned domain. Requires domains:read when using an API key.",
|
|
2776
|
+
inputSchema: {
|
|
2777
|
+
domain_id: z.number().describe("Owned domain ID from list_domains"),
|
|
2778
|
+
},
|
|
2779
|
+
}, async ({ domain_id }) => {
|
|
2780
|
+
const { data } = await apiRequest("GET", `/account/domains/${domain_id}/dnssec-ds`);
|
|
2781
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2782
|
+
});
|
|
2783
|
+
server.registerTool("add_domain_parent_ds", {
|
|
2784
|
+
description: "Queue adding DNSSEC DS records at the parent zone for an owned domain. Requires domains:manage when using an API key.",
|
|
2785
|
+
inputSchema: {
|
|
2786
|
+
domain_id: z.number().describe("Owned domain ID from list_domains"),
|
|
2787
|
+
ds: z
|
|
2788
|
+
.string()
|
|
2789
|
+
.min(8)
|
|
2790
|
+
.describe("One or more DS records, one per line. Accepted forms include '12345 13 2 ABCDEF...' or full BIND-style DS lines."),
|
|
2791
|
+
idempotencyKey: idempotencyKeySchema.describe("Unique key for this queued domain action; sent as Idempotency-Key"),
|
|
2792
|
+
},
|
|
2793
|
+
}, async ({ domain_id, ds, idempotencyKey }) => {
|
|
2794
|
+
const { data } = await apiRequest("POST", `/account/domains/${domain_id}/dnssec-ds`,
|
|
2795
|
+
// The API's preferred key is ds_records; bare `ds` is only a
|
|
2796
|
+
// compatibility fallback, so do not depend on it.
|
|
2797
|
+
{ ds_records: ds, idempotencyKey }, { "Idempotency-Key": idempotencyKey });
|
|
2798
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2799
|
+
});
|
|
2800
|
+
server.registerTool("delete_domain_parent_ds", {
|
|
2801
|
+
description: "Queue deleting DNSSEC DS records at the parent zone for an owned domain. Requires domains:manage when using an API key.",
|
|
2802
|
+
inputSchema: {
|
|
2803
|
+
domain_id: z.number().describe("Owned domain ID from list_domains"),
|
|
2804
|
+
ds: z
|
|
2805
|
+
.string()
|
|
2806
|
+
.min(8)
|
|
2807
|
+
.describe("One or more DS records to remove, one per line. Accepted forms include '12345 13 2 ABCDEF...' or full BIND-style DS lines."),
|
|
2808
|
+
idempotencyKey: idempotencyKeySchema.describe("Unique key for this queued domain action; sent as Idempotency-Key"),
|
|
2809
|
+
},
|
|
2810
|
+
}, async ({ domain_id, ds, idempotencyKey }) => {
|
|
2811
|
+
const { data } = await apiRequest("DELETE", `/account/domains/${domain_id}/dnssec-ds`,
|
|
2812
|
+
// The API's preferred key is ds_records; bare `ds` is only a
|
|
2813
|
+
// compatibility fallback, so do not depend on it.
|
|
2814
|
+
{ ds_records: ds, idempotencyKey }, { "Idempotency-Key": idempotencyKey });
|
|
2815
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2816
|
+
});
|
|
2817
|
+
server.registerTool("check_domain_availability", {
|
|
2818
|
+
description: "Check read-only domain availability. Requires domains:read when using an API key.",
|
|
2819
|
+
inputSchema: {
|
|
2820
|
+
domain: z.string().describe("Domain to check, e.g. example.lt or example.com"),
|
|
2821
|
+
},
|
|
2822
|
+
}, async ({ domain }) => {
|
|
2823
|
+
const { data } = await apiRequest("GET", `/account/domains/check?domain=${encodeURIComponent(domain)}`);
|
|
2824
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2825
|
+
});
|
|
2826
|
+
const getDomainOrderingStatus = async () => {
|
|
2827
|
+
const { data } = await apiRequest("GET", "/account/domains/status");
|
|
2828
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2829
|
+
};
|
|
2830
|
+
server.registerTool("get_domain_ordering_status", {
|
|
2831
|
+
description: "Show non-secret domain ordering readiness. Requires domains:read when using an API key.",
|
|
2832
|
+
inputSchema: {},
|
|
2833
|
+
}, getDomainOrderingStatus);
|
|
2834
|
+
server.registerTool("list_domain_contacts", {
|
|
2835
|
+
description: "List domain registrant/admin/tech/billing contacts owned by the account. Requires domains:read when using an API key.",
|
|
2836
|
+
inputSchema: {},
|
|
2837
|
+
}, async () => {
|
|
2838
|
+
const { data } = await apiRequest("GET", "/account/domains/contacts");
|
|
2839
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2840
|
+
});
|
|
2841
|
+
const domainContactInputSchema = {
|
|
2842
|
+
kind: z.enum(["individual", "business"]).describe("Contact kind"),
|
|
2843
|
+
is_default: z.boolean().optional().describe("Make this the default domain contact"),
|
|
2844
|
+
first_name: z.string().optional().describe("Required for individual contacts"),
|
|
2845
|
+
last_name: z.string().optional().describe("Required for individual contacts"),
|
|
2846
|
+
organization: z.string().optional().describe("Required for business contacts"),
|
|
2847
|
+
organization_code: z.string().optional().describe("Company/legal entity code required for business contacts"),
|
|
2848
|
+
email: z.string().email().describe("Contact email"),
|
|
2849
|
+
phone: z.string().optional().describe("Phone number, preferably E.164"),
|
|
2850
|
+
address1: z.string().optional().describe("Street address"),
|
|
2851
|
+
address2: z.string().optional().describe("Street address line 2"),
|
|
2852
|
+
city: z.string().optional().describe("City"),
|
|
2853
|
+
state: z.string().optional().describe("State/province"),
|
|
2854
|
+
postal_code: z.string().optional().describe("Postal code"),
|
|
2855
|
+
country_code: z.string().length(2).describe("ISO-3166 alpha-2 country code"),
|
|
2856
|
+
vat_code: z.string().optional().describe("VAT code when relevant"),
|
|
2857
|
+
};
|
|
2858
|
+
server.registerTool("create_domain_contact", {
|
|
2859
|
+
description: "Create a domain contact for future domain registration/transfer. Requires domains:manage when using an API key.",
|
|
2860
|
+
inputSchema: domainContactInputSchema,
|
|
2861
|
+
}, async (input) => {
|
|
2862
|
+
const { data } = await apiRequest("POST", "/account/domains/contacts", input);
|
|
2863
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2864
|
+
});
|
|
2865
|
+
server.registerTool("update_domain_contact", {
|
|
2866
|
+
description: "Update an existing domain contact. Requires domains:manage when using an API key.",
|
|
2867
|
+
inputSchema: {
|
|
2868
|
+
id: z.number().describe("Domain contact ID"),
|
|
2869
|
+
...domainContactInputSchema,
|
|
2870
|
+
},
|
|
2871
|
+
}, async ({ id, ...input }) => {
|
|
2872
|
+
const { data } = await apiRequest("POST", `/account/domains/contacts/${id}`, input);
|
|
2873
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2874
|
+
});
|
|
2875
|
+
server.registerTool("delete_domain_contact", {
|
|
2876
|
+
description: "Delete an unused domain contact. Contacts referenced by domains are rejected. Requires domains:manage when using an API key.",
|
|
2877
|
+
inputSchema: {
|
|
2878
|
+
id: z.number().describe("Domain contact ID"),
|
|
2879
|
+
},
|
|
2880
|
+
}, async ({ id }) => {
|
|
2881
|
+
const { data } = await apiRequest("DELETE", `/account/domains/contacts/${id}`);
|
|
2882
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2883
|
+
});
|
|
2884
|
+
server.registerTool("quote_domain_register", {
|
|
2885
|
+
description: "Create a VPSNet-priced domain registration quote. Returns quoteToken; does not submit the domain action. Requires domains:read plus paid domains:order scope/caps for API keys.",
|
|
2886
|
+
inputSchema: {
|
|
2887
|
+
domain: z.string().describe("Domain to register, e.g. example.lt"),
|
|
2888
|
+
years: z.number().optional().describe("Registration period in years"),
|
|
2889
|
+
registrant_contact_id: z.number().optional().describe("Registrant contact ID; defaults to account default contact"),
|
|
2890
|
+
admin_contact_id: z.number().optional().describe("Admin contact ID"),
|
|
2891
|
+
tech_contact_id: z.number().optional().describe("Technical contact ID"),
|
|
2892
|
+
billing_contact_id: z.number().optional().describe("Billing contact ID"),
|
|
2893
|
+
nameservers: z.array(nameserverHostnameSchema).optional().describe("2-13 nameserver hostnames; defaults to ns1/ns2.vpsnet.com. IP addresses are not accepted."),
|
|
2894
|
+
whois_privacy: whoisPrivacySchema,
|
|
2895
|
+
idempotencyKey: idempotencyKeySchema.describe("Unique key for this quote; sent as Idempotency-Key"),
|
|
2896
|
+
},
|
|
2897
|
+
}, async ({ idempotencyKey, ...input }) => {
|
|
2898
|
+
const body = { ...input, idempotencyKey };
|
|
2899
|
+
const { data } = await apiRequest("POST", "/account/domains/register/quote", body, { "Idempotency-Key": idempotencyKey });
|
|
2900
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2901
|
+
});
|
|
2902
|
+
server.registerTool("quote_domain_transfer", {
|
|
2903
|
+
description: "Create a VPSNet-priced domain transfer quote. Auth code is hashed into the quote payload and not returned. Returns quoteToken; does not submit the domain action. Requires domains:read plus paid domains:transfer scope/caps for API keys.",
|
|
2904
|
+
inputSchema: {
|
|
2905
|
+
domain: z.string().describe("Domain to transfer"),
|
|
2906
|
+
authCode: z.string().describe("Transfer auth/EPP code"),
|
|
2907
|
+
registrant_contact_id: z.number().optional().describe("Registrant contact ID; defaults to account default contact"),
|
|
2908
|
+
admin_contact_id: z.number().optional().describe("Admin contact ID"),
|
|
2909
|
+
tech_contact_id: z.number().optional().describe("Technical contact ID"),
|
|
2910
|
+
billing_contact_id: z.number().optional().describe("Billing contact ID"),
|
|
2911
|
+
nameservers: z.array(nameserverHostnameSchema).optional().describe("2-13 nameserver hostnames; defaults to ns1/ns2.vpsnet.com. IP addresses are not accepted."),
|
|
2912
|
+
whois_privacy: whoisPrivacySchema,
|
|
2913
|
+
idempotencyKey: idempotencyKeySchema.describe("Unique key for this quote; sent as Idempotency-Key"),
|
|
2914
|
+
},
|
|
2915
|
+
}, async ({ idempotencyKey, authCode, ...input }) => {
|
|
2916
|
+
const body = { ...input, auth_code: authCode, idempotencyKey };
|
|
2917
|
+
const { data } = await apiRequest("POST", "/account/domains/transfer/quote", body, { "Idempotency-Key": idempotencyKey });
|
|
2918
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2919
|
+
});
|
|
2920
|
+
const domainPaymentSchema = z
|
|
2921
|
+
.object({
|
|
2922
|
+
payment: z.number().describe("Payment method ID. Use 1 for balance payment"),
|
|
2923
|
+
successUrl: z.string().describe("Redirect URL on success (use empty string '')"),
|
|
2924
|
+
cancelUrl: z.string().describe("Redirect URL on cancel (use empty string '')"),
|
|
2925
|
+
})
|
|
2926
|
+
.passthrough()
|
|
2927
|
+
.describe("Payment object. For balance: { payment: 1, successUrl: '', cancelUrl: '' }");
|
|
2928
|
+
server.registerTool("confirm_domain_register", {
|
|
2929
|
+
description: "Confirm a quoted domain registration and pay from the VPSNet account. Domain payments are non-refundable once confirmed and the domain action is queued only after payment succeeds. Requires the same idempotencyKey and quoteToken from quote_domain_register.",
|
|
2930
|
+
inputSchema: {
|
|
2931
|
+
domain: z.string().describe("Domain to register, exactly as quoted"),
|
|
2932
|
+
years: z.number().optional().describe("Registration period in years, exactly as quoted"),
|
|
2933
|
+
registrant_contact_id: z.number().optional().describe("Registrant contact ID used in quote"),
|
|
2934
|
+
admin_contact_id: z.number().optional().describe("Admin contact ID used in quote"),
|
|
2935
|
+
tech_contact_id: z.number().optional().describe("Technical contact ID used in quote"),
|
|
2936
|
+
billing_contact_id: z.number().optional().describe("Billing contact ID used in quote"),
|
|
2937
|
+
nameservers: z.array(nameserverHostnameSchema).optional().describe("Nameserver hostnames used in quote"),
|
|
2938
|
+
whois_privacy: whoisPrivacySchema,
|
|
2939
|
+
idempotencyKey: idempotencyKeySchema.describe("Same idempotencyKey used for quote"),
|
|
2940
|
+
quoteToken: z.string().min(32).describe("quoteToken returned by quote_domain_register"),
|
|
2941
|
+
payment: domainPaymentSchema,
|
|
2942
|
+
},
|
|
2943
|
+
}, async ({ idempotencyKey, quoteToken, ...input }) => {
|
|
2944
|
+
const body = { ...input, idempotencyKey, quoteToken };
|
|
2945
|
+
const { data } = await apiRequest("POST", "/account/domains/register/confirm", body, { "Idempotency-Key": idempotencyKey, "X-Quote-Token": quoteToken });
|
|
2946
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2947
|
+
});
|
|
2948
|
+
server.registerTool("confirm_domain_transfer", {
|
|
2949
|
+
description: "Confirm a quoted domain transfer and pay from the VPSNet account. Domain payments are non-refundable once confirmed and the transfer is queued only after payment succeeds. Requires the same idempotencyKey, quoteToken, and authCode used for quote.",
|
|
2950
|
+
inputSchema: {
|
|
2951
|
+
domain: z.string().describe("Domain to transfer, exactly as quoted"),
|
|
2952
|
+
authCode: z.string().describe("Transfer auth/EPP code used in quote; stored encrypted server-side after confirm"),
|
|
2953
|
+
registrant_contact_id: z.number().optional().describe("Registrant contact ID used in quote"),
|
|
2954
|
+
admin_contact_id: z.number().optional().describe("Admin contact ID used in quote"),
|
|
2955
|
+
tech_contact_id: z.number().optional().describe("Technical contact ID used in quote"),
|
|
2956
|
+
billing_contact_id: z.number().optional().describe("Billing contact ID used in quote"),
|
|
2957
|
+
nameservers: z.array(nameserverHostnameSchema).optional().describe("Nameserver hostnames used in quote"),
|
|
2958
|
+
whois_privacy: whoisPrivacySchema,
|
|
2959
|
+
idempotencyKey: idempotencyKeySchema.describe("Same idempotencyKey used for quote"),
|
|
2960
|
+
quoteToken: z.string().min(32).describe("quoteToken returned by quote_domain_transfer"),
|
|
2961
|
+
payment: domainPaymentSchema,
|
|
2962
|
+
},
|
|
2963
|
+
}, async ({ idempotencyKey, quoteToken, authCode, ...input }) => {
|
|
2964
|
+
const body = { ...input, auth_code: authCode, idempotencyKey, quoteToken };
|
|
2965
|
+
const { data } = await apiRequest("POST", "/account/domains/transfer/confirm", body, { "Idempotency-Key": idempotencyKey, "X-Quote-Token": quoteToken });
|
|
2966
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2967
|
+
});
|
|
2968
|
+
server.registerTool("quote_domain_renew", {
|
|
2969
|
+
description: "Create a VPSNet-priced renewal quote for an owned domain. Renewal pricing follows the existing domain record. Requires paid domains:renew scope/caps for API keys.",
|
|
2970
|
+
inputSchema: {
|
|
2971
|
+
domain_id: z.number().optional().describe("Owned domain ID"),
|
|
2972
|
+
domain: z.string().optional().describe("Owned domain name if domain_id is not used"),
|
|
2973
|
+
years: z.number().optional().describe("Renewal period in years"),
|
|
2974
|
+
idempotencyKey: idempotencyKeySchema.describe("Unique key for this quote; sent as Idempotency-Key"),
|
|
2975
|
+
},
|
|
2976
|
+
}, async ({ idempotencyKey, ...input }) => {
|
|
2977
|
+
const body = { ...input, idempotencyKey };
|
|
2978
|
+
const { data } = await apiRequest("POST", "/account/domains/renew/quote", body, { "Idempotency-Key": idempotencyKey });
|
|
2979
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2980
|
+
});
|
|
2981
|
+
server.registerTool("confirm_domain_renew", {
|
|
2982
|
+
description: "Confirm a quoted domain renewal and pay from the VPSNet account. Domain renewal payments are non-refundable once confirmed and the renewal is queued only after payment succeeds. Requires the same idempotencyKey and quoteToken from quote_domain_renew.",
|
|
2983
|
+
inputSchema: {
|
|
2984
|
+
domain_id: z.number().optional().describe("Owned domain ID used in quote"),
|
|
2985
|
+
domain: z.string().optional().describe("Owned domain name used in quote"),
|
|
2986
|
+
years: z.number().optional().describe("Renewal period used in quote"),
|
|
2987
|
+
idempotencyKey: idempotencyKeySchema.describe("Same idempotencyKey used for quote"),
|
|
2988
|
+
quoteToken: z.string().min(32).describe("quoteToken returned by quote_domain_renew"),
|
|
2989
|
+
payment: domainPaymentSchema,
|
|
2990
|
+
},
|
|
2991
|
+
}, async ({ idempotencyKey, quoteToken, ...input }) => {
|
|
2992
|
+
const body = { ...input, idempotencyKey, quoteToken };
|
|
2993
|
+
const { data } = await apiRequest("POST", "/account/domains/renew/confirm", body, { "Idempotency-Key": idempotencyKey, "X-Quote-Token": quoteToken });
|
|
2994
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
2995
|
+
});
|
|
2996
|
+
server.registerTool("quote_domain_restore", {
|
|
2997
|
+
description: "Create a restore quote for an owned domain in redemption. The final price is returned before payment. Requires paid domains:renew scope/caps for API keys.",
|
|
2998
|
+
inputSchema: {
|
|
2999
|
+
domain_id: z.number().optional().describe("Owned domain ID"),
|
|
3000
|
+
domain: z.string().optional().describe("Owned domain name if domain_id is not used"),
|
|
3001
|
+
idempotencyKey: idempotencyKeySchema.describe("Unique key for this quote; sent as Idempotency-Key"),
|
|
3002
|
+
},
|
|
3003
|
+
}, async ({ idempotencyKey, ...input }) => {
|
|
3004
|
+
const body = { ...input, idempotencyKey };
|
|
3005
|
+
const { data } = await apiRequest("POST", "/account/domains/restore/quote", body, { "Idempotency-Key": idempotencyKey });
|
|
3006
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3007
|
+
});
|
|
3008
|
+
server.registerTool("confirm_domain_restore", {
|
|
3009
|
+
description: "Confirm a quoted domain restore and pay from the VPSNet account. Domain restore payments are non-refundable once confirmed and the restore is queued only after payment succeeds. Requires the same idempotencyKey and quoteToken from quote_domain_restore.",
|
|
3010
|
+
inputSchema: {
|
|
3011
|
+
domain_id: z.number().optional().describe("Owned domain ID used in quote"),
|
|
3012
|
+
domain: z.string().optional().describe("Owned domain name used in quote"),
|
|
3013
|
+
idempotencyKey: idempotencyKeySchema.describe("Same idempotencyKey used for quote"),
|
|
3014
|
+
quoteToken: z.string().min(32).describe("quoteToken returned by quote_domain_restore"),
|
|
3015
|
+
payment: domainPaymentSchema,
|
|
3016
|
+
},
|
|
3017
|
+
}, async ({ idempotencyKey, quoteToken, ...input }) => {
|
|
3018
|
+
const body = { ...input, idempotencyKey, quoteToken };
|
|
3019
|
+
const { data } = await apiRequest("POST", "/account/domains/restore/confirm", body, { "Idempotency-Key": idempotencyKey, "X-Quote-Token": quoteToken });
|
|
3020
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3021
|
+
});
|
|
3022
|
+
server.registerTool("set_domain_auto_renew", {
|
|
3023
|
+
description: "Enable or disable automatic renewal for an owned domain. Auto-renew charges the VPSNet account; automatic domain renewal payments are non-refundable once confirmed.",
|
|
3024
|
+
inputSchema: {
|
|
3025
|
+
domain_id: z.number().describe("Owned domain ID from list_domains"),
|
|
3026
|
+
enabled: z.boolean().describe("Whether automatic renewal should be enabled"),
|
|
3027
|
+
renewal_period_years: z.number().min(1).max(10).optional().describe("Renewal period in years; defaults to the current domain setting"),
|
|
3028
|
+
},
|
|
3029
|
+
}, async ({ domain_id, enabled, renewal_period_years }) => {
|
|
3030
|
+
const { data } = await apiRequest("POST", `/account/domains/${domain_id}/auto-renew`, {
|
|
3031
|
+
enabled,
|
|
3032
|
+
...(renewal_period_years ? { renewal_period_years } : {}),
|
|
3033
|
+
});
|
|
3034
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3035
|
+
});
|
|
3036
|
+
server.registerTool("get_registrar_lock", {
|
|
3037
|
+
description: "Get the registrar transfer-lock status for an owned domain (locked / unlocked / unsupported). A locked domain cannot be transferred away until unlocked. Changing the lock is intentionally not available via API key (use the control panel). Requires domains:read when using an API key.",
|
|
3038
|
+
inputSchema: {
|
|
3039
|
+
domain_id: z.number().describe("Owned domain ID from list_domains"),
|
|
3040
|
+
},
|
|
3041
|
+
}, async ({ domain_id }) => {
|
|
3042
|
+
const { data } = await apiRequest("GET", `/account/domains/${domain_id}/registrar-lock`);
|
|
3043
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3044
|
+
});
|
|
3045
|
+
server.registerTool("list_service_dns_options", {
|
|
3046
|
+
description: "List the DNS attach options for a service: its public IPv4/IPv6 addresses, the account's editable forward DNS zones, and DNS records already pointing at the service. Use before attach_service_dns_record. Requires dns:read when using an API key.",
|
|
3047
|
+
inputSchema: {
|
|
3048
|
+
orderNo: z.string().describe("Order number, e.g. VP57068"),
|
|
3049
|
+
},
|
|
3050
|
+
}, async ({ orderNo }) => {
|
|
3051
|
+
const { data } = await apiRequest("GET", `/account/services/${orderNo}/domains`);
|
|
3052
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3053
|
+
});
|
|
3054
|
+
server.registerTool("attach_service_dns_record", {
|
|
3055
|
+
description: "Point a DNS name at a service in one call: creates an A/AAAA/CNAME record in an owned forward DNS zone using the service's own IP (A/AAAA default to the service IP when ip is omitted; the ip, when given, must belong to the service). Not for secondary/suspended zones. For arbitrary record content use upsert_dns_record instead. Requires dns:write when using an API key.",
|
|
3056
|
+
inputSchema: {
|
|
3057
|
+
orderNo: z.string().describe("Order number of the service, e.g. VP57068"),
|
|
3058
|
+
zone_id: z.number().describe("Owned forward DNS zone ID from list_dns_zones"),
|
|
3059
|
+
name: z
|
|
3060
|
+
.string()
|
|
3061
|
+
.describe("Record name inside the zone, e.g. 'www' or '@' for the apex"),
|
|
3062
|
+
type: z.enum(["A", "AAAA", "CNAME"]).describe("Record type"),
|
|
3063
|
+
ip: z
|
|
3064
|
+
.string()
|
|
3065
|
+
.optional()
|
|
3066
|
+
.describe("Optional service IP for A/AAAA; defaults to the service's first matching IP"),
|
|
3067
|
+
target: z
|
|
3068
|
+
.string()
|
|
3069
|
+
.optional()
|
|
3070
|
+
.describe("CNAME target hostname (required when type=CNAME)"),
|
|
3071
|
+
ttl: z.number().optional().describe("TTL in seconds (default 120)"),
|
|
3072
|
+
},
|
|
3073
|
+
}, async ({ orderNo, zone_id, name, type, ip, target, ttl }) => {
|
|
3074
|
+
const body = { zone_id, name, type };
|
|
3075
|
+
if (ip !== undefined)
|
|
3076
|
+
body.ip = ip;
|
|
3077
|
+
if (target !== undefined)
|
|
3078
|
+
body.target = target;
|
|
3079
|
+
if (ttl !== undefined)
|
|
3080
|
+
body.ttl = ttl;
|
|
3081
|
+
const { data } = await apiRequest("POST", `/account/services/${orderNo}/domains`, body);
|
|
3082
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3083
|
+
});
|
|
3084
|
+
server.registerTool("list_snapshots", {
|
|
3085
|
+
description: "List disk snapshots for a Cloud VPS (KVM/VDS) service, with the snapshot billing policy (free window, then billed per GB while kept) and a usage summary. Firecracker VPS uses list_firecracker_snapshots instead. Requires services:read when called with an API key.",
|
|
3086
|
+
inputSchema: {
|
|
3087
|
+
orderNo: z.string().describe("Order number, e.g. VD12345"),
|
|
3088
|
+
},
|
|
3089
|
+
}, async ({ orderNo }) => {
|
|
3090
|
+
const { data } = await apiRequest("GET", `/account/services/${orderNo}/snapshots`);
|
|
3091
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3092
|
+
});
|
|
3093
|
+
server.registerTool("create_snapshot", {
|
|
3094
|
+
description: "Create a disk snapshot of a Cloud VPS (KVM/VDS) service. Free for a short window, then billed per GB while kept (see list_snapshots policy). Take a snapshot before any risky or automated change — it's free for an initial window, so it's cheap insurance you can roll back to. DELETE the snapshot once the change succeeds and you no longer need it — after the free window it is billed per GB while kept (Cloud VPS snapshots do NOT auto-expire), so never leave snapshots lying around. Only one snapshot action can run at a time; snapshot count is limited per service. Requires services:manage and a full-access API key.",
|
|
3095
|
+
inputSchema: {
|
|
3096
|
+
orderNo: z.string().describe("Order number"),
|
|
3097
|
+
description: z.string().optional().describe("Optional snapshot description"),
|
|
3098
|
+
},
|
|
3099
|
+
}, async ({ orderNo, description }) => {
|
|
3100
|
+
const body = {};
|
|
3101
|
+
if (description !== undefined)
|
|
3102
|
+
body.description = description;
|
|
3103
|
+
const { data } = await apiRequest("POST", `/account/services/${orderNo}/snapshots`, body);
|
|
3104
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3105
|
+
});
|
|
3106
|
+
server.registerTool("rollback_snapshot", {
|
|
3107
|
+
description: "Roll a Cloud VPS (KVM/VDS) service back to a disk snapshot. DESTRUCTIVE: disk state after the snapshot is lost. Confirm with the user before calling. Tip: take a snapshot before any risky or automated change — it's free for an initial window, so it's cheap insurance you can roll back to. DELETE the snapshot once the change succeeds and you no longer need it — after the free window it is billed per GB while kept (Cloud VPS snapshots do NOT auto-expire), so never leave snapshots lying around. Requires services:manage and a full-access API key.",
|
|
3108
|
+
inputSchema: {
|
|
3109
|
+
orderNo: z.string().describe("Order number"),
|
|
3110
|
+
snapname: z.string().describe("Snapshot name from list_snapshots"),
|
|
3111
|
+
},
|
|
3112
|
+
}, async ({ orderNo, snapname }) => {
|
|
3113
|
+
const { data } = await apiRequest("POST", `/account/services/${orderNo}/snapshots/${encodeURIComponent(snapname)}/rollback`);
|
|
3114
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3115
|
+
});
|
|
3116
|
+
server.registerTool("delete_snapshot", {
|
|
3117
|
+
description: "Delete a Cloud VPS (KVM/VDS) disk snapshot. Requires services:manage and a full-access API key.",
|
|
3118
|
+
inputSchema: {
|
|
3119
|
+
orderNo: z.string().describe("Order number"),
|
|
3120
|
+
snapname: z.string().describe("Snapshot name from list_snapshots"),
|
|
3121
|
+
},
|
|
3122
|
+
}, async ({ orderNo, snapname }) => {
|
|
3123
|
+
const { data } = await apiRequest("DELETE", `/account/services/${orderNo}/snapshots/${encodeURIComponent(snapname)}`);
|
|
3124
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3125
|
+
});
|
|
3126
|
+
server.registerTool("list_firecracker_snapshots", {
|
|
3127
|
+
description: "List temporary snapshots for a Firecracker VPS service, including billing state (free window, then a per-GB keep rate) and expiry. Requires services:read when called with an API key.",
|
|
3128
|
+
inputSchema: {
|
|
3129
|
+
orderNo: z.string().describe("Order number, e.g. VP57068"),
|
|
3130
|
+
},
|
|
3131
|
+
}, async ({ orderNo }) => {
|
|
3132
|
+
const { data } = await apiRequest("GET", `/account/services/${orderNo}/firecracker/snapshots`);
|
|
3133
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3134
|
+
});
|
|
3135
|
+
server.registerTool("create_firecracker_snapshot", {
|
|
3136
|
+
description: "Create a temporary snapshot of a Firecracker VPS. Free for a short window, then billed per GB while kept until its automatic expiry. Take a snapshot before any risky or automated change, and delete it once the change succeeds to stop keep billing early. Check list_firecracker_snapshots for the exact policy and expiry fields. Requires services:manage and a full-access API key.",
|
|
3137
|
+
inputSchema: {
|
|
3138
|
+
orderNo: z.string().describe("Order number"),
|
|
3139
|
+
description: z
|
|
3140
|
+
.string()
|
|
3141
|
+
.optional()
|
|
3142
|
+
.describe("Optional note/label for the snapshot (helps identify what it was taken before)"),
|
|
3143
|
+
},
|
|
3144
|
+
}, async ({ orderNo, description }) => {
|
|
3145
|
+
const body = {};
|
|
3146
|
+
if (description !== undefined)
|
|
3147
|
+
body.description = description;
|
|
3148
|
+
const { data } = await apiRequest("POST", `/account/services/${orderNo}/firecracker/snapshots`, body);
|
|
3149
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3150
|
+
});
|
|
3151
|
+
server.registerTool("rollback_firecracker_snapshot", {
|
|
3152
|
+
description: "Roll a Firecracker VPS back to a temporary snapshot. DESTRUCTIVE: disk state after the snapshot is lost. Confirm with the user before calling. Firecracker snapshots expire automatically but remain billed after the free window until deletion or expiry, so delete an unneeded snapshot to stop keep billing early. Requires services:manage and a full-access API key.",
|
|
3153
|
+
inputSchema: {
|
|
3154
|
+
orderNo: z.string().describe("Order number"),
|
|
3155
|
+
snapshot_id: z.number().describe("Snapshot ID from list_firecracker_snapshots"),
|
|
3156
|
+
},
|
|
3157
|
+
}, async ({ orderNo, snapshot_id }) => {
|
|
3158
|
+
const { data } = await apiRequest("POST", `/account/services/${orderNo}/firecracker/snapshots/${snapshot_id}/rollback`);
|
|
3159
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3160
|
+
});
|
|
3161
|
+
server.registerTool("delete_firecracker_snapshot", {
|
|
3162
|
+
description: "Delete a Firecracker VPS temporary snapshot (stops its keep billing). Requires services:manage and a full-access API key.",
|
|
3163
|
+
inputSchema: {
|
|
3164
|
+
orderNo: z.string().describe("Order number"),
|
|
3165
|
+
snapshot_id: z.number().describe("Snapshot ID from list_firecracker_snapshots"),
|
|
3166
|
+
},
|
|
3167
|
+
}, async ({ orderNo, snapshot_id }) => {
|
|
3168
|
+
const { data } = await apiRequest("DELETE", `/account/services/${orderNo}/firecracker/snapshots/${snapshot_id}`);
|
|
3169
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3170
|
+
});
|
|
3171
|
+
server.registerTool("get_restore_status", {
|
|
3172
|
+
description: "Get the unified restore state for a service: retention days, restore price, and any restore request in progress. Cloud VPS and Firecracker VPS have automatic daily off-node backups restored through this flow. Requires services:read plus the services:restore paid scope when called with an API key (the response includes account balance).",
|
|
3173
|
+
inputSchema: {
|
|
3174
|
+
orderNo: z.string().describe("Order number"),
|
|
3175
|
+
},
|
|
3176
|
+
}, async ({ orderNo }) => {
|
|
3177
|
+
const { data } = await apiRequest("GET", `/account/services/${orderNo}/restore/status`);
|
|
3178
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3179
|
+
});
|
|
3180
|
+
server.registerTool("list_restore_points", {
|
|
3181
|
+
description: "List available backup restore points for a service (automatic off-node backups). Use a point id with request_restore. Requires services:read when called with an API key.",
|
|
3182
|
+
inputSchema: {
|
|
3183
|
+
orderNo: z.string().describe("Order number"),
|
|
3184
|
+
},
|
|
3185
|
+
}, async ({ orderNo }) => {
|
|
3186
|
+
const { data } = await apiRequest("GET", `/account/services/${orderNo}/restore/points`);
|
|
3187
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3188
|
+
});
|
|
3189
|
+
/**
|
|
3190
|
+
* Project a refused whole-service restore confirmation into something the
|
|
3191
|
+
* caller can act on.
|
|
3192
|
+
*
|
|
3193
|
+
* The two refusals below arrive as a bare `{ "restoreQuoteChanged": true }` or
|
|
3194
|
+
* `{ "restoreRequestInvalid": true }` body. An MCP caller never sees the HTTP
|
|
3195
|
+
* status, so returned as-is they are a small JSON object that reads no
|
|
3196
|
+
* differently from a successful restore — the one outcome an agent must not
|
|
3197
|
+
* mistake. Both are refused by the API before any ledger row or payment
|
|
3198
|
+
* exists, so in both cases nothing was restored and nothing was charged, and
|
|
3199
|
+
* saying so is the whole point: it is what tells the caller a retry is safe.
|
|
3200
|
+
*/
|
|
3201
|
+
function restoreConfirmRefusal(data) {
|
|
3202
|
+
const body = (data ?? {});
|
|
3203
|
+
if (body.restoreQuoteChanged === true) {
|
|
3204
|
+
return {
|
|
3205
|
+
isError: true,
|
|
3206
|
+
content: [{
|
|
3207
|
+
type: "text",
|
|
3208
|
+
text: formatJson({
|
|
3209
|
+
success: false,
|
|
3210
|
+
error_codes: ["restoreQuoteChanged"],
|
|
3211
|
+
reason: "The restore price moved between the quote and the confirmation, so the restore was refused and the account was not charged.",
|
|
3212
|
+
fix: "Do not resend the old figure. Call get_restore_status again, tell the user the total it now reports, get their approval for that new figure, then call request_restore again with expected_total_charged set to it and a fresh idempotencyKey (omit it to have one generated).",
|
|
3213
|
+
}),
|
|
3214
|
+
}],
|
|
3215
|
+
};
|
|
3216
|
+
}
|
|
3217
|
+
if (body.restoreRequestInvalid === true) {
|
|
3218
|
+
return {
|
|
3219
|
+
isError: true,
|
|
3220
|
+
content: [{
|
|
3221
|
+
type: "text",
|
|
3222
|
+
text: formatJson({
|
|
3223
|
+
success: false,
|
|
3224
|
+
error_codes: ["restoreRequestInvalid"],
|
|
3225
|
+
reason: "The API refused the confirmation body itself, so no restore was started and the account was not charged.",
|
|
3226
|
+
fix: "This is a client-side contract fault, not something the user can answer: the body this tool sends no longer matches what the restore endpoint accepts. Retrying sends the same body and is refused the same way — report the failure and tell the user to run the restore from the VPSnet panel instead.",
|
|
3227
|
+
}),
|
|
3228
|
+
}],
|
|
3229
|
+
};
|
|
3230
|
+
}
|
|
3231
|
+
return null;
|
|
3232
|
+
}
|
|
3233
|
+
server.registerTool("request_restore", {
|
|
3234
|
+
description: "PAID: restore a service from a backup point. Charges the restore price (+VAT) from the ACCOUNT BALANCE immediately and overwrites the WHOLE service disk with the backup content — everything written since that point is lost. DESTRUCTIVE and billed. This is a confirmation call, not a request for a price: read get_restore_status first, show the user the exact backup point and that status's exact total_charged, tell them the disk is replaced, and obtain explicit approval for BOTH the charge and the replacement. Only then set acknowledge_data_replacement and acknowledge_restore_charge, and pass the very figure you disclosed as expected_total_charged. The tool runs the backend's quote → confirm flow itself under one Idempotency-Key (generated per call unless idempotencyKey is given), so a single call is a single paid attempt. If the price has moved since you disclosed it, nothing is charged: the restore is refused as restoreQuoteChanged, and you must show the user the new total and get their approval again. Requires services:read, a full-access API key with paid operations enabled, the services:restore paid scope, and configured spend caps.",
|
|
3235
|
+
inputSchema: {
|
|
3236
|
+
orderNo: z.string().describe("Order number"),
|
|
3237
|
+
backup_point_id: z.number().describe("Restore point ID from list_restore_points"),
|
|
3238
|
+
acknowledge_data_replacement: z
|
|
3239
|
+
.literal(true)
|
|
3240
|
+
.describe("True only after the user explicitly confirms that the whole service disk is overwritten with this backup point and that everything written since it is lost"),
|
|
3241
|
+
acknowledge_restore_charge: z
|
|
3242
|
+
.literal(true)
|
|
3243
|
+
.describe("True only after the user explicitly approves the exact total_charged returned by get_restore_status being taken from the account balance"),
|
|
3244
|
+
expected_total_charged: z
|
|
3245
|
+
.number()
|
|
3246
|
+
.finite()
|
|
3247
|
+
.nonnegative()
|
|
3248
|
+
.describe("The exact VAT-inclusive total_charged you read from get_restore_status and disclosed to the user, passed back unchanged. Never re-read it just before this call: it is the customer's agreed price, and sending a freshly fetched number would defeat the fence that refuses a moved price instead of quietly charging it."),
|
|
3249
|
+
idempotencyKey: paidIdempotencyKeySchema
|
|
3250
|
+
.optional()
|
|
3251
|
+
.describe("Optional stable key to retry or replay ONE exact earlier restore attempt without paying twice. Omit it for a new restore — a fresh key is generated for the call."),
|
|
3252
|
+
},
|
|
3253
|
+
annotations: {
|
|
3254
|
+
title: "Restore service from backup",
|
|
3255
|
+
readOnlyHint: false,
|
|
3256
|
+
destructiveHint: true,
|
|
3257
|
+
idempotentHint: true,
|
|
3258
|
+
},
|
|
3259
|
+
}, async ({ orderNo, backup_point_id, acknowledge_data_replacement, acknowledge_restore_charge, expected_total_charged, idempotencyKey, }) => {
|
|
3260
|
+
// The backend contract for API-key callers is quote → confirm with the
|
|
3261
|
+
// SAME Idempotency-Key plus the quoteToken minted by the quote step. A
|
|
3262
|
+
// bare confirm is refused (idempotencyKeyRequired / quoteTokenRequired),
|
|
3263
|
+
// so both steps happen here, exactly like order_service.
|
|
3264
|
+
//
|
|
3265
|
+
// Since backend 1fbcf253a the confirm is also a confirmation of an
|
|
3266
|
+
// already-disclosed price: ServiceRestoreController::restoreInput(true)
|
|
3267
|
+
// refuses it with 422 restoreRequestInvalid unless both acknowledgements
|
|
3268
|
+
// are literally true and expectedTotalCharged is a finite number >= 0,
|
|
3269
|
+
// and refuses the charge with 409 restoreQuoteChanged when the live quote
|
|
3270
|
+
// has moved more than half a cent from it. Every POST to
|
|
3271
|
+
// /restore/requests goes through that check, replays included, so the
|
|
3272
|
+
// three fields go on the replay body too. The allow-list there is exact:
|
|
3273
|
+
// any key outside it is a 422, so nothing else may be added.
|
|
3274
|
+
const confirmation = {
|
|
3275
|
+
acknowledgeDataReplacement: acknowledge_data_replacement,
|
|
3276
|
+
acknowledgeRestoreCharge: acknowledge_restore_charge,
|
|
3277
|
+
expectedTotalCharged: expected_total_charged,
|
|
3278
|
+
};
|
|
3279
|
+
const key = idempotencyKey ?? randomUUID();
|
|
3280
|
+
const quote = await apiRequest("POST", `/account/services/${orderNo}/restore/requests/quote`, { backup_point_id }, { "Idempotency-Key": key });
|
|
3281
|
+
const quoteToken = quote.data
|
|
3282
|
+
?.quoteToken;
|
|
3283
|
+
if (!quoteToken) {
|
|
3284
|
+
const alreadyUsed = quote.data?.idempotencyKeyAlreadyUsed === true;
|
|
3285
|
+
if (!alreadyUsed) {
|
|
3286
|
+
// Surface the quote-stage denial (scope, paid-ops, spend cap, missing
|
|
3287
|
+
// point, ...) directly: it names the real reason, which a follow-up
|
|
3288
|
+
// confirm would only mask behind quoteTokenRequired.
|
|
3289
|
+
return { content: [{ type: "text", text: formatJson(quote.data) }] };
|
|
3290
|
+
}
|
|
3291
|
+
// The key was already spent on an earlier attempt. Confirm without a
|
|
3292
|
+
// token: the backend resolves the same key to the existing restore
|
|
3293
|
+
// request and replays it without charging again.
|
|
3294
|
+
const replay = await apiRequest("POST", `/account/services/${orderNo}/restore/requests`, { backup_point_id, ...confirmation }, { "Idempotency-Key": key });
|
|
3295
|
+
// restoreInput() runs ahead of the replay lookup, so a replay is refused
|
|
3296
|
+
// by the same fence as a first attempt and needs the same explanation.
|
|
3297
|
+
return (restoreConfirmRefusal(replay.data)
|
|
3298
|
+
?? { content: [{ type: "text", text: formatJson(replay.data) }] });
|
|
3299
|
+
}
|
|
3300
|
+
// The server re-checks the drift and refuses before any ledger row or
|
|
3301
|
+
// payment exists, so this is not what makes the confirm safe -- it is
|
|
3302
|
+
// what makes the refusal legible. A bare 409 restoreQuoteChanged does not
|
|
3303
|
+
// carry the new price, and the confirm would have consumed this key, so
|
|
3304
|
+
// stop here instead and hand back the number the user has to approve
|
|
3305
|
+
// next. Same tolerance as the server's QUOTE_TOLERANCE.
|
|
3306
|
+
const liveTotal = quote.data?.quote?.total_charged;
|
|
3307
|
+
if (typeof liveTotal === "number"
|
|
3308
|
+
&& Math.abs(liveTotal - expected_total_charged) > 0.005) {
|
|
3309
|
+
return {
|
|
3310
|
+
isError: true,
|
|
3311
|
+
content: [{
|
|
3312
|
+
type: "text",
|
|
3313
|
+
text: formatJson({
|
|
3314
|
+
success: false,
|
|
3315
|
+
error_codes: ["restoreQuoteChanged"],
|
|
3316
|
+
expected_total_charged,
|
|
3317
|
+
current_total_charged: liveTotal,
|
|
3318
|
+
reason: "The restore price moved after it was disclosed, so nothing was confirmed and nothing was charged.",
|
|
3319
|
+
fix: "Tell the user the new total, get their approval for it, then call request_restore again with expected_total_charged set to that new total and a fresh idempotencyKey (omit it to have one generated).",
|
|
3320
|
+
}),
|
|
3321
|
+
}],
|
|
3322
|
+
};
|
|
3323
|
+
}
|
|
3324
|
+
const { data } = await apiRequest("POST", `/account/services/${orderNo}/restore/requests`, { backup_point_id, quoteToken, ...confirmation }, { "Idempotency-Key": key, "X-Quote-Token": quoteToken });
|
|
3325
|
+
// The pre-check above only knows the total this quote returned; the price
|
|
3326
|
+
// can still move before the confirm lands, and the server checks it again.
|
|
3327
|
+
return (restoreConfirmRefusal(data)
|
|
3328
|
+
?? { content: [{ type: "text", text: formatJson(data) }] });
|
|
3329
|
+
});
|
|
3330
|
+
// --- Backup file browsing (free, read-only) ---
|
|
3331
|
+
server.registerTool("list_restore_file_points", {
|
|
3332
|
+
description: "List the nightly backup points whose contents can be browsed file by file for one owned service, and report whether folder search is available on that service's node. Browsing is free and never charges the account or changes the server. Always call this before browse_restore_files: its searchAvailable flag is the only reliable signal of whether the filter argument can be used. Initially supported on Firecracker VPS. Requires services:read.",
|
|
3333
|
+
inputSchema: {
|
|
3334
|
+
orderNo: serviceOrderNoSchema,
|
|
3335
|
+
},
|
|
3336
|
+
annotations: {
|
|
3337
|
+
title: "List browsable backup points",
|
|
3338
|
+
readOnlyHint: true,
|
|
3339
|
+
destructiveHint: false,
|
|
3340
|
+
idempotentHint: true,
|
|
3341
|
+
},
|
|
3342
|
+
}, async ({ orderNo }) => {
|
|
3343
|
+
const { status, data } = await apiRequest("GET", svc(encodeURIComponent(orderNo), "restore/files/points"));
|
|
3344
|
+
return {
|
|
3345
|
+
content: [{
|
|
3346
|
+
type: "text",
|
|
3347
|
+
text: formatJson(safeFileBrowsePointsPayload(status, data)),
|
|
3348
|
+
}],
|
|
3349
|
+
};
|
|
3350
|
+
});
|
|
3351
|
+
server.registerTool("browse_restore_files", {
|
|
3352
|
+
description: "Queue a free, read-only listing of one directory inside a backup point. This does NOT restore anything, does not charge the account, and does not modify the server. Start at the backup root by passing only backupPointId. To open a subdirectory, pass sourceBrowseId plus the directoryEntryId of a type=directory entry from that result; filesystem paths are never accepted. A directory can hold an enormous number of files, so the server selects pages of 200 or 1,000 entries: while result.nextOffset is non-null, call again with the same sourceBrowseId and directoryEntryId and offset set to result.nextOffset. Use result.pageSize rather than assuming 200 when moving backwards. When nextOffset is null the listing cannot be paged further even if truncated is true — that is a capped scan; check result.listingStatus: 'complete' means the folder was read end to end, 'partial' means the listing is a bounded lower-bound slice, so never present it as the whole folder. Optionally pass filter to search entry NAMES in that one directory (substring, case-insensitive, never recursive) — but only when list_restore_file_points reported searchAvailable=true, otherwise this tool refuses rather than silently returning an unfiltered listing. The call is asynchronous: it returns a browse id in a pending state, and you must poll get_restore_file_browse until state is succeeded. Requires services:read and, because it is a POST, an API key that permits write operations plus an idempotencyKey.",
|
|
3353
|
+
inputSchema: {
|
|
3354
|
+
orderNo: serviceOrderNoSchema,
|
|
3355
|
+
backupPointId: fileBrowsePointIdSchema,
|
|
3356
|
+
sourceBrowseId: fileBrowseIdSchema
|
|
3357
|
+
.optional()
|
|
3358
|
+
.describe("Browse id of the succeeded listing the directory entry came from. Omit to list the backup root."),
|
|
3359
|
+
directoryEntryId: fileBrowseDirectoryEntryIdSchema.optional(),
|
|
3360
|
+
offset: fileBrowseOffsetSchema
|
|
3361
|
+
.optional()
|
|
3362
|
+
.describe("Copy a non-null result.nextOffset to page further through the SAME directory (a null nextOffset means the listing cannot be paged further). Must be 0 (or omitted) when starting a root listing or entering a directory."),
|
|
3363
|
+
filter: fileBrowseFilterSchema.optional(),
|
|
3364
|
+
idempotencyKey: idempotencyKeySchema.describe("Client-global unique key for this exact browse request; reuse it only to replay the same request."),
|
|
3365
|
+
},
|
|
3366
|
+
annotations: {
|
|
3367
|
+
title: "Browse backup files",
|
|
3368
|
+
readOnlyHint: true,
|
|
3369
|
+
destructiveHint: false,
|
|
3370
|
+
idempotentHint: true,
|
|
3371
|
+
},
|
|
3372
|
+
}, async ({ orderNo, backupPointId, sourceBrowseId, directoryEntryId, offset, filter, idempotencyKey, }) => {
|
|
3373
|
+
const rejection = fileBrowseRequestRejection({
|
|
3374
|
+
sourceBrowseId,
|
|
3375
|
+
directoryEntryId,
|
|
3376
|
+
offset,
|
|
3377
|
+
});
|
|
3378
|
+
if (rejection !== null) {
|
|
3379
|
+
return {
|
|
3380
|
+
isError: true,
|
|
3381
|
+
content: [{
|
|
3382
|
+
type: "text",
|
|
3383
|
+
text: formatJson({ success: false, error: rejection }),
|
|
3384
|
+
}],
|
|
3385
|
+
};
|
|
3386
|
+
}
|
|
3387
|
+
// Folder search depends on a node capability that older workers do not
|
|
3388
|
+
// report. The browse POST refuses a filter it cannot serve with
|
|
3389
|
+
// serviceFileBrowseSearchUnavailable, so this probe is not what makes the
|
|
3390
|
+
// refusal safe -- it is what makes it legible: it names the reason and the
|
|
3391
|
+
// way forward here rather than surfacing a bare error code from a POST
|
|
3392
|
+
// that has already consumed the caller's idempotency key. Either way an
|
|
3393
|
+
// unfiltered listing is never returned in a search's place, because a
|
|
3394
|
+
// caller would trust a file list that silently ignored the search term.
|
|
3395
|
+
if (filter !== undefined) {
|
|
3396
|
+
const probe = await apiRequest("GET", svc(encodeURIComponent(orderNo), "restore/files/points"));
|
|
3397
|
+
if (probe.status < 200
|
|
3398
|
+
|| probe.status >= 300
|
|
3399
|
+
|| readSearchAvailable(probe.data) !== true) {
|
|
3400
|
+
return {
|
|
3401
|
+
isError: true,
|
|
3402
|
+
content: [{
|
|
3403
|
+
type: "text",
|
|
3404
|
+
text: formatJson({
|
|
3405
|
+
success: false,
|
|
3406
|
+
error_codes: ["serviceFileBrowseSearchUnavailable"],
|
|
3407
|
+
reason: "Folder search is not available for this service right now, so the filter was not applied and no file list is being returned. This node's worker does not report the backup folder-search capability.",
|
|
3408
|
+
fix: "Browse the directory without filter and page through it with offset, or retry search later once the service's node reports the capability. Do not treat an unfiltered listing as a search result.",
|
|
3409
|
+
}),
|
|
3410
|
+
}],
|
|
3411
|
+
};
|
|
3412
|
+
}
|
|
3413
|
+
}
|
|
3414
|
+
const { status, data } = await apiRequest("POST", svc(encodeURIComponent(orderNo), "restore/files/browses"), fileBrowseRequestBody({
|
|
3415
|
+
backupPointId,
|
|
3416
|
+
sourceBrowseId,
|
|
3417
|
+
directoryEntryId,
|
|
3418
|
+
offset,
|
|
3419
|
+
filter,
|
|
3420
|
+
}), { "Idempotency-Key": idempotencyKey });
|
|
3421
|
+
return {
|
|
3422
|
+
content: [{
|
|
3423
|
+
type: "text",
|
|
3424
|
+
text: formatJson(safeFileBrowsePayload(status, data)),
|
|
3425
|
+
}],
|
|
3426
|
+
};
|
|
3427
|
+
});
|
|
3428
|
+
server.registerTool("get_restore_file_browse", {
|
|
3429
|
+
description: "Poll one queued backup file listing. Free and read-only. Keep polling while state is queued, running, or awaiting_reply; entries are present only when state is succeeded. A failed state carries an errorCode and no listing — report that rather than presenting an empty or partial directory as the real contents. Requires services:read.",
|
|
3430
|
+
inputSchema: {
|
|
3431
|
+
orderNo: serviceOrderNoSchema,
|
|
3432
|
+
browse_id: fileBrowseIdSchema,
|
|
3433
|
+
},
|
|
3434
|
+
annotations: {
|
|
3435
|
+
title: "Get backup file listing",
|
|
3436
|
+
readOnlyHint: true,
|
|
3437
|
+
destructiveHint: false,
|
|
3438
|
+
idempotentHint: true,
|
|
3439
|
+
},
|
|
3440
|
+
}, async ({ orderNo, browse_id }) => {
|
|
3441
|
+
const { status, data } = await apiRequest("GET", svc(encodeURIComponent(orderNo), `restore/files/browses/${encodeURIComponent(browse_id)}`));
|
|
3442
|
+
return {
|
|
3443
|
+
content: [{
|
|
3444
|
+
type: "text",
|
|
3445
|
+
text: formatJson(safeFileBrowsePayload(status, data)),
|
|
3446
|
+
}],
|
|
3447
|
+
};
|
|
3448
|
+
});
|
|
3449
|
+
server.registerTool("get_guest_agent_status", {
|
|
3450
|
+
description: "Check whether the QEMU guest agent is running inside a Cloud VPS (KVM/VDS). Useful before OS-level operations that depend on the agent. Requires services:read when called with an API key.",
|
|
3451
|
+
inputSchema: {
|
|
3452
|
+
orderNo: z.string().describe("Order number"),
|
|
3453
|
+
},
|
|
3454
|
+
}, async ({ orderNo }) => {
|
|
3455
|
+
const { data } = await apiRequest("GET", `/account/services/${orderNo}/guest-agent`);
|
|
3456
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3457
|
+
});
|
|
3458
|
+
const functionBodyFromInput = (input) => {
|
|
3459
|
+
const body = {};
|
|
3460
|
+
for (const key of [
|
|
3461
|
+
"name",
|
|
3462
|
+
"runtime_os_id",
|
|
3463
|
+
"entrypoint",
|
|
3464
|
+
"description",
|
|
3465
|
+
"code",
|
|
3466
|
+
"environment",
|
|
3467
|
+
"timeout_seconds",
|
|
3468
|
+
"vcpus",
|
|
3469
|
+
"memory_mb",
|
|
3470
|
+
"enabled",
|
|
3471
|
+
"webhook_enabled",
|
|
3472
|
+
"rotate_webhook_secret",
|
|
3473
|
+
"acknowledge_unreadable_replacement",
|
|
3474
|
+
]) {
|
|
3475
|
+
if (input[key] !== undefined)
|
|
3476
|
+
body[key] = input[key];
|
|
3477
|
+
}
|
|
3478
|
+
return body;
|
|
3479
|
+
};
|
|
3480
|
+
const functionFieldsSchema = {
|
|
3481
|
+
name: z.string().optional().describe("Function name"),
|
|
3482
|
+
runtime_os_id: z.number().optional().describe("Runtime OS ID (see list_functions response for available runtimes)"),
|
|
3483
|
+
entrypoint: z.string().optional().describe("Entrypoint command/handler"),
|
|
3484
|
+
description: z.string().optional().describe("Description (max 2000 chars)"),
|
|
3485
|
+
code: z.string().optional().describe("Function source code"),
|
|
3486
|
+
environment: z.string().optional().describe("Environment variables as JSON object string"),
|
|
3487
|
+
timeout_seconds: z.number().optional().describe("1-300 seconds, default 30"),
|
|
3488
|
+
vcpus: z.number().optional().describe("1-8 vCPUs, default 1"),
|
|
3489
|
+
memory_mb: z.number().optional().describe("64-8192 MB, default 256"),
|
|
3490
|
+
enabled: z.boolean().optional().describe("Whether the function can be invoked"),
|
|
3491
|
+
webhook_enabled: z.boolean().optional().describe("Expose a public webhook URL for this function"),
|
|
3492
|
+
rotate_webhook_secret: z.boolean().optional().describe("Rotate the webhook secret"),
|
|
3493
|
+
};
|
|
3494
|
+
server.registerTool("list_functions", {
|
|
3495
|
+
description: "List Firecracker Functions (serverless-style code execution in microVMs) with runtimes, webhook URLs, and usage. Usage-billed from the account.",
|
|
3496
|
+
inputSchema: {},
|
|
3497
|
+
}, async () => {
|
|
3498
|
+
const { data } = await apiRequest("GET", "/account/firecracker/functions");
|
|
3499
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3500
|
+
});
|
|
3501
|
+
server.registerTool("get_function", {
|
|
3502
|
+
description: "Get one Firecracker Function with code, config, and webhook details.",
|
|
3503
|
+
inputSchema: {
|
|
3504
|
+
function_id: z.number().describe("Function ID from list_functions"),
|
|
3505
|
+
},
|
|
3506
|
+
}, async ({ function_id }) => {
|
|
3507
|
+
const { data } = await apiRequest("GET", `/account/firecracker/functions/${function_id}`);
|
|
3508
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3509
|
+
});
|
|
3510
|
+
server.registerTool("create_function", {
|
|
3511
|
+
description: "Create a Firecracker Function. Requires name, runtime_os_id, and code. Invocations are usage-billed (CPU + memory seconds) from the account balance.",
|
|
3512
|
+
inputSchema: functionFieldsSchema,
|
|
3513
|
+
}, async (input) => {
|
|
3514
|
+
const { data } = await apiRequest("POST", "/account/firecracker/functions", functionBodyFromInput(input));
|
|
3515
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3516
|
+
});
|
|
3517
|
+
server.registerTool("update_function", {
|
|
3518
|
+
description: "Update a Firecracker Function's code or configuration. Call get_function first. If integrity.unreadable_fields names code or environment, the backend refuses the update unless the user explicitly approves replacing every unavailable value from a trusted copy and acknowledge_unreadable_replacement=true is passed. Leave that flag false for ordinary updates.",
|
|
3519
|
+
inputSchema: {
|
|
3520
|
+
function_id: z.number().describe("Function ID from list_functions"),
|
|
3521
|
+
...functionFieldsSchema,
|
|
3522
|
+
acknowledge_unreadable_replacement: z.boolean().optional().describe("Explicit customer approval to replace code or environment named by integrity.unreadable_fields; default false"),
|
|
3523
|
+
},
|
|
3524
|
+
}, async ({ function_id, ...input }) => {
|
|
3525
|
+
const { data } = await apiRequest("POST", `/account/firecracker/functions/${function_id}`, functionBodyFromInput(input));
|
|
3526
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3527
|
+
});
|
|
3528
|
+
server.registerTool("delete_function", {
|
|
3529
|
+
description: "Delete a Firecracker Function.",
|
|
3530
|
+
inputSchema: {
|
|
3531
|
+
function_id: z.number().describe("Function ID from list_functions"),
|
|
3532
|
+
},
|
|
3533
|
+
}, async ({ function_id }) => {
|
|
3534
|
+
const { data } = await apiRequest("DELETE", `/account/firecracker/functions/${function_id}`);
|
|
3535
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3536
|
+
});
|
|
3537
|
+
server.registerTool("invoke_function", {
|
|
3538
|
+
description: "Invoke a Firecracker Function. PAID per invocation (CPU/memory usage billed from account). With wait=true the call blocks and returns the result; otherwise poll list_function_invocations. One Idempotency-Key is generated per tool call unless idempotency_key is supplied; reuse an explicit key only for an exact retry so the same logical step is not double-run or double-billed (response may include replayed=true).",
|
|
3539
|
+
inputSchema: {
|
|
3540
|
+
function_id: z.number().describe("Function ID from list_functions"),
|
|
3541
|
+
input: z
|
|
3542
|
+
.string()
|
|
3543
|
+
.optional()
|
|
3544
|
+
.describe("Input payload passed to the function (string; JSON text is fine)"),
|
|
3545
|
+
wait: z.boolean().optional().describe("Wait synchronously for the result"),
|
|
3546
|
+
idempotency_key: paidIdempotencyKeySchema
|
|
3547
|
+
.optional()
|
|
3548
|
+
.describe("Optional stable key for this exact logical invoke (e.g. agent step id). Exact retries with the same key return the original invocation; changed input or wait semantics are refused."),
|
|
3549
|
+
},
|
|
3550
|
+
annotations: {
|
|
3551
|
+
title: "Invoke Firecracker Function",
|
|
3552
|
+
readOnlyHint: false,
|
|
3553
|
+
destructiveHint: true,
|
|
3554
|
+
idempotentHint: true,
|
|
3555
|
+
openWorldHint: true,
|
|
3556
|
+
},
|
|
3557
|
+
}, async ({ function_id, input, wait, idempotency_key }) => {
|
|
3558
|
+
const body = {};
|
|
3559
|
+
if (input !== undefined)
|
|
3560
|
+
body.input = input;
|
|
3561
|
+
if (wait !== undefined)
|
|
3562
|
+
body.wait = wait;
|
|
3563
|
+
const key = idempotency_key ?? randomUUID();
|
|
3564
|
+
const { data } = await apiRequest("POST", `/account/firecracker/functions/${function_id}/invoke`, body, { "Idempotency-Key": key });
|
|
3565
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3566
|
+
});
|
|
3567
|
+
server.registerTool("list_function_invocations", {
|
|
3568
|
+
description: "List invocations of a Firecracker Function with status, duration, and cost.",
|
|
3569
|
+
inputSchema: {
|
|
3570
|
+
function_id: z.number().describe("Function ID from list_functions"),
|
|
3571
|
+
},
|
|
3572
|
+
}, async ({ function_id }) => {
|
|
3573
|
+
const { data } = await apiRequest("GET", `/account/firecracker/functions/${function_id}/invocations`);
|
|
3574
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3575
|
+
});
|
|
3576
|
+
server.registerTool("get_function_invocation", {
|
|
3577
|
+
description: "Get one invocation of a Firecracker Function including output/logs and usage cost.",
|
|
3578
|
+
inputSchema: {
|
|
3579
|
+
function_id: z.number().describe("Function ID from list_functions"),
|
|
3580
|
+
invocation_id: z.string().describe("Invocation ID from list_function_invocations"),
|
|
3581
|
+
},
|
|
3582
|
+
}, async ({ function_id, invocation_id }) => {
|
|
3583
|
+
const { data } = await apiRequest("GET", `/account/firecracker/functions/${function_id}/invocations/${encodeURIComponent(invocation_id)}`);
|
|
3584
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3585
|
+
});
|
|
3586
|
+
// --- On-demand server sessions (stable temp_vm API identifiers) ---
|
|
3587
|
+
server.registerTool("get_temp_vm_options", {
|
|
3588
|
+
description: "Get the coming-soon on-demand server preview options and authoritative orderable/availability launch state. Host placement is never returned. Requires services:read.",
|
|
3589
|
+
inputSchema: {},
|
|
3590
|
+
annotations: {
|
|
3591
|
+
title: "Get on-demand server options",
|
|
3592
|
+
readOnlyHint: true,
|
|
3593
|
+
destructiveHint: false,
|
|
3594
|
+
idempotentHint: true,
|
|
3595
|
+
openWorldHint: true,
|
|
3596
|
+
},
|
|
3597
|
+
}, async () => {
|
|
3598
|
+
const { status, data } = await apiRequest("GET", "/account/firecracker/temp-vms/options");
|
|
3599
|
+
return {
|
|
3600
|
+
content: [{
|
|
3601
|
+
type: "text",
|
|
3602
|
+
text: formatJson(safeTempVmPayload(status, data)),
|
|
3603
|
+
}],
|
|
3604
|
+
};
|
|
3605
|
+
});
|
|
3606
|
+
server.registerTool("quote_temp_vm", {
|
|
3607
|
+
description: "Coming soon: customer ordering is disabled. The backend returns tempVmComingSoon before payment, host, IP, or service allocation.",
|
|
3608
|
+
inputSchema: {
|
|
3609
|
+
profile: tempVmProfileSchema.optional(),
|
|
3610
|
+
ttl_minutes: tempVmTtlSchema.optional(),
|
|
3611
|
+
idempotency_key: tempVmIdempotencyKeySchema
|
|
3612
|
+
.optional()
|
|
3613
|
+
.describe("Optional new key for this quote. Omit it to generate one; the result returns the generated key."),
|
|
3614
|
+
},
|
|
3615
|
+
annotations: {
|
|
3616
|
+
title: "Quote on-demand server session (Coming soon)",
|
|
3617
|
+
readOnlyHint: false,
|
|
3618
|
+
destructiveHint: false,
|
|
3619
|
+
idempotentHint: false,
|
|
3620
|
+
openWorldHint: true,
|
|
3621
|
+
},
|
|
3622
|
+
}, async ({ profile, ttl_minutes, idempotency_key }) => {
|
|
3623
|
+
const key = idempotency_key ?? randomUUID();
|
|
3624
|
+
const body = {};
|
|
3625
|
+
if (profile !== undefined)
|
|
3626
|
+
body.profile = profile;
|
|
3627
|
+
if (ttl_minutes !== undefined)
|
|
3628
|
+
body.ttl_minutes = ttl_minutes;
|
|
3629
|
+
const { status, data } = await apiRequest("POST", "/account/firecracker/temp-vms/quote", body, { "Idempotency-Key": key });
|
|
3630
|
+
const payload = safeTempVmPayload(status, data);
|
|
3631
|
+
const result = payload.success === true
|
|
3632
|
+
? { ...payload, idempotency_key: key }
|
|
3633
|
+
: payload;
|
|
3634
|
+
return { content: [{ type: "text", text: formatJson(result) }] };
|
|
3635
|
+
});
|
|
3636
|
+
server.registerTool("list_temp_vms", {
|
|
3637
|
+
description: "List the account's on-demand server sessions and current options. Credentials and internal host placement are never returned. Requires services:read.",
|
|
3638
|
+
inputSchema: {},
|
|
3639
|
+
annotations: {
|
|
3640
|
+
title: "List on-demand server sessions",
|
|
3641
|
+
readOnlyHint: true,
|
|
3642
|
+
destructiveHint: false,
|
|
3643
|
+
idempotentHint: true,
|
|
3644
|
+
openWorldHint: true,
|
|
3645
|
+
},
|
|
3646
|
+
}, async () => {
|
|
3647
|
+
const { status, data } = await apiRequest("GET", "/account/firecracker/temp-vms");
|
|
3648
|
+
return {
|
|
3649
|
+
content: [{
|
|
3650
|
+
type: "text",
|
|
3651
|
+
text: formatJson(safeTempVmPayload(status, data)),
|
|
3652
|
+
}],
|
|
3653
|
+
};
|
|
3654
|
+
});
|
|
3655
|
+
server.registerTool("create_temp_vm", {
|
|
3656
|
+
description: "Coming soon: customer ordering is disabled. The backend returns tempVmComingSoon before any paid intent, charge, host, IP, or service allocation.",
|
|
3657
|
+
inputSchema: {
|
|
3658
|
+
profile: tempVmProfileSchema.optional(),
|
|
3659
|
+
ttl_minutes: tempVmTtlSchema.optional(),
|
|
3660
|
+
os_id: tempVmOsIdSchema.optional(),
|
|
3661
|
+
root_password: tempVmRootPasswordSchema.optional(),
|
|
3662
|
+
ssh_public_key: tempVmSshPublicKeySchema.optional(),
|
|
3663
|
+
idempotency_key: tempVmIdempotencyKeySchema,
|
|
3664
|
+
quote_token: tempVmQuoteTokenSchema,
|
|
3665
|
+
acknowledge_price_eur: z
|
|
3666
|
+
.number()
|
|
3667
|
+
.min(0.5)
|
|
3668
|
+
.describe("Exact gross EUR amount disclosed by quote_temp_vm and approved by the user"),
|
|
3669
|
+
acknowledge_no_backups: z
|
|
3670
|
+
.literal(true)
|
|
3671
|
+
.describe("Confirm the disclosed data policy: automatic backups and snapshots are not included, so required data must be stored elsewhere before server deletion"),
|
|
3672
|
+
},
|
|
3673
|
+
annotations: {
|
|
3674
|
+
title: "Create on-demand server session (Coming soon)",
|
|
3675
|
+
readOnlyHint: false,
|
|
3676
|
+
destructiveHint: true,
|
|
3677
|
+
idempotentHint: true,
|
|
3678
|
+
openWorldHint: true,
|
|
3679
|
+
},
|
|
3680
|
+
}, async ({ profile, ttl_minutes, os_id, root_password, ssh_public_key, idempotency_key, quote_token, acknowledge_price_eur, acknowledge_no_backups, }) => {
|
|
3681
|
+
if (acknowledge_no_backups !== true || acknowledge_price_eur < 0.5) {
|
|
3682
|
+
throw new Error("Explicit price and data-policy acknowledgement is required.");
|
|
3683
|
+
}
|
|
3684
|
+
if (root_password !== undefined && ssh_public_key !== undefined) {
|
|
3685
|
+
throw new Error("Choose either root_password or ssh_public_key, not both.");
|
|
3686
|
+
}
|
|
3687
|
+
const body = {
|
|
3688
|
+
public_ip: true,
|
|
3689
|
+
idempotencyKey: idempotency_key,
|
|
3690
|
+
quoteToken: quote_token,
|
|
3691
|
+
};
|
|
3692
|
+
if (profile !== undefined)
|
|
3693
|
+
body.profile = profile;
|
|
3694
|
+
if (ttl_minutes !== undefined)
|
|
3695
|
+
body.ttl_minutes = ttl_minutes;
|
|
3696
|
+
if (os_id !== undefined)
|
|
3697
|
+
body.os_id = os_id;
|
|
3698
|
+
if (root_password !== undefined)
|
|
3699
|
+
body.rootPassword = root_password;
|
|
3700
|
+
if (ssh_public_key !== undefined)
|
|
3701
|
+
body.ssh_public_key = ssh_public_key;
|
|
3702
|
+
const { status, data } = await apiRequest("POST", "/account/firecracker/temp-vms", body, {
|
|
3703
|
+
"Idempotency-Key": idempotency_key,
|
|
3704
|
+
"X-Quote-Token": quote_token,
|
|
3705
|
+
});
|
|
3706
|
+
return {
|
|
3707
|
+
content: [{
|
|
3708
|
+
type: "text",
|
|
3709
|
+
text: formatJson(safeTempVmPayload(status, data)),
|
|
3710
|
+
}],
|
|
3711
|
+
};
|
|
3712
|
+
});
|
|
3713
|
+
server.registerTool("get_temp_vm", {
|
|
3714
|
+
description: "Get one tenant-owned on-demand server session. Credentials and internal host placement are never returned. Requires services:read.",
|
|
3715
|
+
inputSchema: { id: tempVmIdSchema },
|
|
3716
|
+
annotations: {
|
|
3717
|
+
title: "Get on-demand server session",
|
|
3718
|
+
readOnlyHint: true,
|
|
3719
|
+
destructiveHint: false,
|
|
3720
|
+
idempotentHint: true,
|
|
3721
|
+
openWorldHint: true,
|
|
3722
|
+
},
|
|
3723
|
+
}, async ({ id }) => {
|
|
3724
|
+
const { status, data } = await apiRequest("GET", `/account/firecracker/temp-vms/${encodeURIComponent(String(id))}`);
|
|
3725
|
+
return {
|
|
3726
|
+
content: [{
|
|
3727
|
+
type: "text",
|
|
3728
|
+
text: formatJson(safeTempVmPayload(status, data)),
|
|
3729
|
+
}],
|
|
3730
|
+
};
|
|
3731
|
+
});
|
|
3732
|
+
server.registerTool("delete_temp_vm", {
|
|
3733
|
+
description: "Permanently delete a tenant-owned on-demand server before expiry. Requires services:manage. This preserves no disk or IP, has no customer-controlled refund, and is refused while payment confirmation is unresolved. Export required data first.",
|
|
3734
|
+
inputSchema: {
|
|
3735
|
+
id: tempVmIdSchema,
|
|
3736
|
+
acknowledge_permanent_destruction: z
|
|
3737
|
+
.literal(true)
|
|
3738
|
+
.describe("Confirm that required data has been exported and permanent deletion is intended"),
|
|
3739
|
+
},
|
|
3740
|
+
annotations: {
|
|
3741
|
+
title: "Permanently delete on-demand server",
|
|
3742
|
+
readOnlyHint: false,
|
|
3743
|
+
destructiveHint: true,
|
|
3744
|
+
idempotentHint: true,
|
|
3745
|
+
openWorldHint: true,
|
|
3746
|
+
},
|
|
3747
|
+
}, async ({ id, acknowledge_permanent_destruction }) => {
|
|
3748
|
+
if (acknowledge_permanent_destruction !== true) {
|
|
3749
|
+
throw new Error("Explicit permanent-destruction acknowledgement is required.");
|
|
3750
|
+
}
|
|
3751
|
+
const { status, data } = await apiRequest("DELETE", `/account/firecracker/temp-vms/${encodeURIComponent(String(id))}`);
|
|
3752
|
+
return {
|
|
3753
|
+
content: [{
|
|
3754
|
+
type: "text",
|
|
3755
|
+
text: formatJson(safeTempVmPayload(status, data)),
|
|
3756
|
+
}],
|
|
3757
|
+
};
|
|
3758
|
+
});
|
|
3759
|
+
server.registerTool("list_dns_zones", {
|
|
3760
|
+
description: "List forward DNS zones on the account. Requires dns:read when using an API key.",
|
|
3761
|
+
inputSchema: {},
|
|
3762
|
+
}, async () => {
|
|
3763
|
+
const { data } = await apiRequest("GET", "/account/dns/zones");
|
|
3764
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3765
|
+
});
|
|
3766
|
+
server.registerTool("create_dns_zone", {
|
|
3767
|
+
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 TXT verification value ONCE in verificationRecord (name _vpsnet-dns.<zone>) - keep it; a lost value is replaced with reissue_dns_zone_verification. 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. Requires dns:write when using an API key.",
|
|
3768
|
+
inputSchema: {
|
|
3769
|
+
zone: z.string().describe("Zone name, e.g. example.com"),
|
|
3770
|
+
kind: dnsZoneKindSchema
|
|
3771
|
+
.optional()
|
|
3772
|
+
.describe("Zone kind. Defaults to native. Use secondary for AXFR-backed zones."),
|
|
3773
|
+
secondary_masters: z
|
|
3774
|
+
.array(z.string())
|
|
3775
|
+
.optional()
|
|
3776
|
+
.describe("Public primary DNS server IPs for a secondary zone. Non-public IPs are rejected by the API."),
|
|
3777
|
+
secondary_tsig_key_name: z
|
|
3778
|
+
.string()
|
|
3779
|
+
.optional()
|
|
3780
|
+
.describe("Required for secondary zones. TSIG key name configured on the primary DNS server."),
|
|
3781
|
+
secondary_tsig_algorithm: z
|
|
3782
|
+
.enum(["hmac-sha256", "hmac-sha384", "hmac-sha512", "hmac-sha224", "hmac-sha1", "hmac-md5"])
|
|
3783
|
+
.optional()
|
|
3784
|
+
.describe("TSIG algorithm. Defaults to hmac-sha256."),
|
|
3785
|
+
secondary_tsig_secret: z
|
|
3786
|
+
.string()
|
|
3787
|
+
.optional()
|
|
3788
|
+
.describe("Required for secondary zones. Base64 TSIG secret, 32-64 decoded bytes, e.g. openssl rand -base64 32. It is encrypted and never returned."),
|
|
3789
|
+
},
|
|
3790
|
+
}, async ({ zone, kind, secondary_masters, secondary_tsig_key_name, secondary_tsig_algorithm, secondary_tsig_secret }) => {
|
|
3791
|
+
const body = {
|
|
3792
|
+
zone_name: zone,
|
|
3793
|
+
};
|
|
3794
|
+
if (kind)
|
|
3795
|
+
body.kind = kind;
|
|
3796
|
+
if (secondary_masters)
|
|
3797
|
+
body.secondary_masters = secondary_masters;
|
|
3798
|
+
if (secondary_tsig_key_name)
|
|
3799
|
+
body.secondary_tsig_key_name = secondary_tsig_key_name;
|
|
3800
|
+
if (secondary_tsig_algorithm)
|
|
3801
|
+
body.secondary_tsig_algorithm = secondary_tsig_algorithm;
|
|
3802
|
+
if (secondary_tsig_secret)
|
|
3803
|
+
body.secondary_tsig_secret = secondary_tsig_secret;
|
|
3804
|
+
const { data } = await apiRequest("POST", "/account/dns/zones", body);
|
|
3805
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3806
|
+
});
|
|
3807
|
+
server.registerTool("get_dns_zone", {
|
|
3808
|
+
description: "Get a DNS zone and desired records. Requires dns:read when using an API key.",
|
|
3809
|
+
inputSchema: {
|
|
3810
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
3811
|
+
},
|
|
3812
|
+
}, async ({ zone_id }) => {
|
|
3813
|
+
const { data } = await apiRequest("GET", `/account/dns/zones/${zone_id}`);
|
|
3814
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3815
|
+
});
|
|
3816
|
+
server.registerTool("get_dns_zone_diagnostics", {
|
|
3817
|
+
description: "Run customer-facing DNS health diagnostics for a zone: delegation, public SOA, DNSSEC signal, and common record hygiene checks. Requires dns:read when using an API key.",
|
|
3818
|
+
inputSchema: {
|
|
3819
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
3820
|
+
},
|
|
3821
|
+
}, async ({ zone_id }) => {
|
|
3822
|
+
const { data } = await apiRequest("GET", `/account/dns/zones/${zone_id}/diagnostics`);
|
|
3823
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3824
|
+
});
|
|
3825
|
+
server.registerTool("export_dns_zone", {
|
|
3826
|
+
description: "Export a native forward DNS zone's desired-state records as a BIND-style zone file. System-managed SOA/apex NS/DNSSEC wire records and PTR/reverse DNS are not exported here. Requires dns:read when using an API key.",
|
|
3827
|
+
inputSchema: {
|
|
3828
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
3829
|
+
},
|
|
3830
|
+
}, async ({ zone_id }) => {
|
|
3831
|
+
const { data } = await apiRequest("GET", `/account/dns/zones/${zone_id}/export`);
|
|
3832
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3833
|
+
});
|
|
3834
|
+
server.registerTool("import_dns_zone", {
|
|
3835
|
+
description: "Import BIND-style forward DNS records into a native zone. Set replace=true to replace non-system desired-state records; false upserts imported records. PTR/reverse DNS, SOA, DNSSEC wire records, apex NS, and LUA are skipped or rejected by the API. Requires dns:write when using an API key.",
|
|
3836
|
+
inputSchema: {
|
|
3837
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
3838
|
+
zonefile: z
|
|
3839
|
+
.string()
|
|
3840
|
+
.min(1)
|
|
3841
|
+
.max(262144)
|
|
3842
|
+
.describe("BIND-style zone file contents to import"),
|
|
3843
|
+
replace: z
|
|
3844
|
+
.boolean()
|
|
3845
|
+
.optional()
|
|
3846
|
+
.describe("If true, delete existing non-system desired-state records before importing. Defaults to false."),
|
|
3847
|
+
},
|
|
3848
|
+
}, async ({ zone_id, zonefile, replace }) => {
|
|
3849
|
+
const { data } = await apiRequest("POST", `/account/dns/zones/${zone_id}/import`, {
|
|
3850
|
+
zonefile,
|
|
3851
|
+
...(replace !== undefined ? { replace } : {}),
|
|
3852
|
+
});
|
|
3853
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3854
|
+
});
|
|
3855
|
+
server.registerTool("list_dns_templates", {
|
|
3856
|
+
description: "List backend-defined DNS record templates for a native forward zone, such as web service, Google Workspace, Microsoft 365, mail security, Null MX and CAA lockdown. Requires dns:read when using an API key.",
|
|
3857
|
+
inputSchema: {
|
|
3858
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
3859
|
+
},
|
|
3860
|
+
}, async ({ zone_id }) => {
|
|
3861
|
+
const { data } = await apiRequest("GET", `/account/dns/zones/${zone_id}/templates`);
|
|
3862
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3863
|
+
});
|
|
3864
|
+
server.registerTool("apply_dns_template", {
|
|
3865
|
+
description: "Apply a backend-defined DNS template to a native forward zone. Records still pass the same API validation, quotas and conflict rules as manual record writes. Prefer preview=true first to show the user exactly which records a template would write before changing the zone. Requires dns:write when using an API key.",
|
|
3866
|
+
inputSchema: {
|
|
3867
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
3868
|
+
template: z
|
|
3869
|
+
.string()
|
|
3870
|
+
.describe("Template ID returned by list_dns_templates, e.g. web_service, null_mx, google_workspace, microsoft_365, mail_security, caa_letsencrypt"),
|
|
3871
|
+
parameters: z
|
|
3872
|
+
.record(z.union([z.string(), z.number(), z.boolean()]))
|
|
3873
|
+
.optional()
|
|
3874
|
+
.describe("Template parameters, e.g. { ipv4, ipv6, www, mx_target, ttl } depending on the template"),
|
|
3875
|
+
preview: z
|
|
3876
|
+
.boolean()
|
|
3877
|
+
.optional()
|
|
3878
|
+
.describe("true to dry-run the template and return the records it would write without changing the zone"),
|
|
3879
|
+
overwrite: z
|
|
3880
|
+
.boolean()
|
|
3881
|
+
.optional()
|
|
3882
|
+
.describe("true to replace existing records that conflict with the template. Destructive: confirm the exact conflicts with the user first, ideally from a preview run."),
|
|
3883
|
+
},
|
|
3884
|
+
}, async ({ zone_id, template, parameters, preview, overwrite }) => {
|
|
3885
|
+
const { data } = await apiRequest("POST", `/account/dns/zones/${zone_id}/templates/${encodeURIComponent(template)}`, {
|
|
3886
|
+
...(parameters !== undefined ? { parameters } : {}),
|
|
3887
|
+
...(preview !== undefined ? { preview } : {}),
|
|
3888
|
+
...(overwrite !== undefined ? { overwrite } : {}),
|
|
3889
|
+
});
|
|
3890
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3891
|
+
});
|
|
3892
|
+
server.registerTool("delete_dns_zone", {
|
|
3893
|
+
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.",
|
|
3894
|
+
inputSchema: {
|
|
3895
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
3896
|
+
},
|
|
3897
|
+
}, async ({ zone_id }) => {
|
|
3898
|
+
const { data } = await apiRequest("DELETE", `/account/dns/zones/${zone_id}`);
|
|
3899
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3900
|
+
});
|
|
3901
|
+
server.registerTool("verify_dns_zone", {
|
|
3902
|
+
description: "Check the zone's ownership TXT value (_vpsnet-dns.<zone>) and publish the zone when it matches: publicly, or served authoritatively by the nameservers the parent zone delegated the name to when the zone was created (the previous DNS provider) - so a domain already pointed at ns1/ns2.vpsnet.com is verified by adding the TXT at the previous provider, never by moving the nameservers back. Delegation to VPSnet nameservers alone is never proof. A refusal (verificationMissing) returns verification.checks (every place checked and what was found) and verification.next_step: add_txt | add_txt_at_previous_provider | add_txt_in_parent_zone | contact_support (only VPSnet support can confirm the domain; the zone is kept, not deleted, while the domain points at VPSnet). lookupUnavailable (HTTP 503) means the platform could not run the DNS check - retry later; it is not a missing record. Requires dns:write when using an API key.",
|
|
3903
|
+
inputSchema: {
|
|
3904
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
3905
|
+
},
|
|
3906
|
+
}, async ({ zone_id }) => {
|
|
3907
|
+
const { data } = await apiRequest("POST", `/account/dns/zones/${zone_id}/verify`);
|
|
3908
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3909
|
+
});
|
|
3910
|
+
server.registerTool("reissue_dns_zone_verification", {
|
|
3911
|
+
description: "Replace a pending DNS zone's ownership TXT value when the one returned by create_dns_zone was lost (only its hash is stored). Returns the new value once in verificationRecord; the previous value stops being accepted, so the TXT record must be updated to the new value. Only for zones in pending_verification. Rate limited to 5 per minute. Requires dns:write when using an API key.",
|
|
3912
|
+
inputSchema: {
|
|
3913
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
3914
|
+
},
|
|
3915
|
+
}, async ({ zone_id }) => {
|
|
3916
|
+
const { data } = await apiRequest("POST", `/account/dns/zones/${zone_id}/verification/reissue`);
|
|
3917
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3918
|
+
});
|
|
3919
|
+
server.registerTool("get_dnssec", {
|
|
3920
|
+
description: "Get DNSSEC state and public DNSKEY/DS material for a native DNS zone. Requires dns:read when using an API key.",
|
|
3921
|
+
inputSchema: {
|
|
3922
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
3923
|
+
},
|
|
3924
|
+
}, async ({ zone_id }) => {
|
|
3925
|
+
const { data } = await apiRequest("GET", `/account/dns/zones/${zone_id}/dnssec`);
|
|
3926
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3927
|
+
});
|
|
3928
|
+
server.registerTool("set_dnssec", {
|
|
3929
|
+
description: "Enable or disable DNSSEC signing for a native DNS zone. If parent DS records still exist outside VPSNet, remove them first and set parent_ds_removed=true before disabling signing. Secondary-zone DNSSEC is controlled on the primary server. Requires dnssec:manage when using an API key.",
|
|
3930
|
+
inputSchema: {
|
|
3931
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
3932
|
+
enabled: z.boolean().describe("true to enable/sign the zone, false to disable/remove signing"),
|
|
3933
|
+
parent_ds_removed: z
|
|
3934
|
+
.boolean()
|
|
3935
|
+
.optional()
|
|
3936
|
+
.describe("When disabling DNSSEC for a zone whose parent DS is managed outside VPSNet, set true only after removing the parent DS records."),
|
|
3937
|
+
},
|
|
3938
|
+
}, async ({ zone_id, enabled, parent_ds_removed }) => {
|
|
3939
|
+
const { data } = await apiRequest("POST", `/account/dns/zones/${zone_id}/dnssec`, {
|
|
3940
|
+
enabled,
|
|
3941
|
+
parent_ds_removed,
|
|
3942
|
+
});
|
|
3943
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3944
|
+
});
|
|
3945
|
+
server.registerTool("upsert_dns_record", {
|
|
3946
|
+
description: "Create or replace a forward DNS record in desired state for a native zone. PTR and *.arpa are rejected; use change_rdns for reverse DNS. Requires dns:write when using an API key.",
|
|
3947
|
+
inputSchema: {
|
|
3948
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
3949
|
+
name: z
|
|
3950
|
+
.string()
|
|
3951
|
+
.describe("Record name. Use @ for apex, or a relative name inside the zone."),
|
|
3952
|
+
type: dnsRecordTypeSchema.describe("DNS record type. PTR is intentionally not supported here."),
|
|
3953
|
+
content: z.string().describe("DNS record value for the selected type"),
|
|
3954
|
+
ttl: z
|
|
3955
|
+
.number()
|
|
3956
|
+
.optional()
|
|
3957
|
+
.describe("TTL in seconds, allowed range 60-604800. Defaults to 120."),
|
|
3958
|
+
comment: z
|
|
3959
|
+
.string()
|
|
3960
|
+
.max(255)
|
|
3961
|
+
.optional()
|
|
3962
|
+
.describe("Optional note about this record, max 255 chars."),
|
|
3963
|
+
},
|
|
3964
|
+
}, async ({ zone_id, name, type, content, ttl, comment }) => {
|
|
3965
|
+
const body = { name, type, content };
|
|
3966
|
+
if (ttl !== undefined)
|
|
3967
|
+
body.ttl = ttl;
|
|
3968
|
+
if (comment !== undefined)
|
|
3969
|
+
body.comment = comment;
|
|
3970
|
+
const { data } = await apiRequest("POST", `/account/dns/zones/${zone_id}/records`, body);
|
|
3971
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
3972
|
+
});
|
|
3973
|
+
server.registerTool("update_dns_record", {
|
|
3974
|
+
description: "Edit an existing forward DNS record by its ID (PUT) — change value, TTL, or comment without replacing it. System-managed records cannot be edited. Requires dns:write when using an API key.",
|
|
3975
|
+
inputSchema: {
|
|
3976
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
3977
|
+
record_id: z.number().describe("DNS record ID to update"),
|
|
3978
|
+
name: z
|
|
3979
|
+
.string()
|
|
3980
|
+
.describe("Record name. Use @ for apex, or a relative name inside the zone."),
|
|
3981
|
+
type: dnsRecordTypeSchema.describe("DNS record type. PTR is intentionally not supported here."),
|
|
3982
|
+
content: z.string().describe("DNS record value for the selected type"),
|
|
3983
|
+
ttl: z
|
|
3984
|
+
.number()
|
|
3985
|
+
.optional()
|
|
3986
|
+
.describe("TTL in seconds, allowed range 60-604800. Defaults to 120."),
|
|
3987
|
+
comment: z
|
|
3988
|
+
.string()
|
|
3989
|
+
.max(255)
|
|
3990
|
+
.optional()
|
|
3991
|
+
.describe("Optional note about this record, max 255 chars."),
|
|
3992
|
+
},
|
|
3993
|
+
}, async ({ zone_id, record_id, name, type, content, ttl, comment }) => {
|
|
3994
|
+
const body = { name, type, content };
|
|
3995
|
+
if (ttl !== undefined)
|
|
3996
|
+
body.ttl = ttl;
|
|
3997
|
+
if (comment !== undefined)
|
|
3998
|
+
body.comment = comment;
|
|
3999
|
+
const { data } = await apiRequest("PUT", `/account/dns/zones/${zone_id}/records/${record_id}`, body);
|
|
4000
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
4001
|
+
});
|
|
4002
|
+
server.registerTool("delete_dns_record", {
|
|
4003
|
+
description: "Delete a forward DNS record from desired state. Requires dns:write when using an API key.",
|
|
4004
|
+
inputSchema: {
|
|
4005
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
4006
|
+
record_id: z.number().describe("DNS record ID"),
|
|
4007
|
+
},
|
|
4008
|
+
}, async ({ zone_id, record_id }) => {
|
|
4009
|
+
const { data } = await apiRequest("DELETE", `/account/dns/zones/${zone_id}/records/${record_id}`);
|
|
4010
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
4011
|
+
});
|
|
4012
|
+
server.registerTool("get_dns_service_status", {
|
|
4013
|
+
description: "Get the VPSNet DNS cluster status: whether the anycast DNS service is operational, healthy node count, the public nameservers to delegate domains to, and the recursive resolver IPs to use on VPSNet servers. Requires dns:read when using an API key.",
|
|
4014
|
+
inputSchema: {},
|
|
4015
|
+
}, async () => {
|
|
4016
|
+
const { data } = await apiRequest("GET", "/account/dns/service-status");
|
|
4017
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
4018
|
+
});
|
|
4019
|
+
server.registerTool("get_dns_zone_history", {
|
|
4020
|
+
description: "Get the recent change history for a DNS zone and its records (action, record, who, when). Useful for auditing what changed. Requires dns:read when using an API key.",
|
|
4021
|
+
inputSchema: {
|
|
4022
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
4023
|
+
},
|
|
4024
|
+
}, async ({ zone_id }) => {
|
|
4025
|
+
const { data } = await apiRequest("GET", `/account/dns/zones/${zone_id}/history`);
|
|
4026
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
4027
|
+
});
|
|
4028
|
+
server.registerTool("list_ddns_tokens", {
|
|
4029
|
+
description: "List DNS updater tokens for a DNS zone (DDNS A/AAAA and ACME DNS-01 TXT). Returned data never includes the full token value. Requires dns:read when using an API key.",
|
|
4030
|
+
inputSchema: {
|
|
4031
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
4032
|
+
},
|
|
4033
|
+
}, async ({ zone_id }) => {
|
|
4034
|
+
const { data } = await apiRequest("GET", `/account/dns/zones/${zone_id}/ddns-tokens`);
|
|
4035
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
4036
|
+
});
|
|
4037
|
+
server.registerTool("create_ddns_token", {
|
|
4038
|
+
description: "Create a narrow DNS updater token for one hostname/pattern inside a verified customer-owned zone. purpose=ddns allows A/AAAA updater use; purpose=acme allows TXT only under _acme-challenge for DNS-01. Set allow_from to restrict updater source IPs/CIDRs. The full token is returned once. Requires dns:write when using an API key.",
|
|
4039
|
+
inputSchema: {
|
|
4040
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
4041
|
+
purpose: z
|
|
4042
|
+
.enum(["ddns", "acme"])
|
|
4043
|
+
.optional()
|
|
4044
|
+
.describe("Token purpose. ddns allows A/AAAA updates; acme allows TXT-only DNS-01 updates under _acme-challenge. Defaults to ddns."),
|
|
4045
|
+
name_pattern: z
|
|
4046
|
+
.string()
|
|
4047
|
+
.describe("Allowed hostname or wildcard pattern. For purpose=acme this must be _acme-challenge.<zone> or _acme-challenge.<host>.<zone>."),
|
|
4048
|
+
record_types: z
|
|
4049
|
+
.string()
|
|
4050
|
+
.optional()
|
|
4051
|
+
.describe("Allowed record types. For purpose=ddns: A, AAAA, or A,AAAA. For purpose=acme: TXT only."),
|
|
4052
|
+
allow_from: z
|
|
4053
|
+
.string()
|
|
4054
|
+
.optional()
|
|
4055
|
+
.describe("Optional comma-separated source IP/CIDR allowlist for updater requests, e.g. 203.0.113.10,2001:db8::/64."),
|
|
4056
|
+
rate_limit_per_minute: z
|
|
4057
|
+
.number()
|
|
4058
|
+
.optional()
|
|
4059
|
+
.describe("Per-token update limit, 1-120/min. Defaults to 10."),
|
|
4060
|
+
expires_at: z
|
|
4061
|
+
.string()
|
|
4062
|
+
.optional()
|
|
4063
|
+
.describe("Optional expiry date (YYYY-MM-DD or datetime)."),
|
|
4064
|
+
},
|
|
4065
|
+
}, async ({ zone_id, purpose, name_pattern, record_types, allow_from, rate_limit_per_minute, expires_at }) => {
|
|
4066
|
+
const body = { name_pattern };
|
|
4067
|
+
if (purpose)
|
|
4068
|
+
body.purpose = purpose;
|
|
4069
|
+
if (record_types)
|
|
4070
|
+
body.record_types = record_types;
|
|
4071
|
+
if (allow_from)
|
|
4072
|
+
body.allow_from = allow_from;
|
|
4073
|
+
if (rate_limit_per_minute !== undefined)
|
|
4074
|
+
body.rate_limit_per_minute = rate_limit_per_minute;
|
|
4075
|
+
if (expires_at)
|
|
4076
|
+
body.expires_at = expires_at;
|
|
4077
|
+
const { data } = await apiRequest("POST", `/account/dns/zones/${zone_id}/ddns-tokens`, body);
|
|
4078
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
4079
|
+
});
|
|
4080
|
+
server.registerTool("revoke_ddns_token", {
|
|
4081
|
+
description: "Revoke a DNS updater token (DDNS or ACME). Requires dns:write when using an API key.",
|
|
4082
|
+
inputSchema: {
|
|
4083
|
+
zone_id: z.number().describe("DNS zone ID"),
|
|
4084
|
+
token_id: z.number().describe("DDNS token ID"),
|
|
4085
|
+
},
|
|
4086
|
+
}, async ({ zone_id, token_id }) => {
|
|
4087
|
+
const { data } = await apiRequest("DELETE", `/account/dns/zones/${zone_id}/ddns-tokens/${token_id}`);
|
|
4088
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
4089
|
+
});
|
|
4090
|
+
// --- Billing ---
|
|
4091
|
+
server.registerTool("list_invoices", {
|
|
4092
|
+
description: "List invoices with pagination",
|
|
4093
|
+
inputSchema: {
|
|
4094
|
+
page: z.number().optional().describe("Page number"),
|
|
4095
|
+
},
|
|
4096
|
+
}, async ({ page }) => {
|
|
4097
|
+
const query = page ? `?page=${page}` : "";
|
|
4098
|
+
const { data } = await apiRequest("GET", `/account/history/invoices${query}`);
|
|
4099
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
4100
|
+
});
|
|
4101
|
+
server.registerTool("get_invoice", {
|
|
4102
|
+
description: "Get a specific invoice by hash",
|
|
4103
|
+
inputSchema: {
|
|
4104
|
+
hash: z.string().describe("Invoice hash"),
|
|
4105
|
+
},
|
|
4106
|
+
}, async ({ hash }) => {
|
|
4107
|
+
const { data } = await apiRequest("GET", `/account/history/invoices/${hash}`);
|
|
4108
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
4109
|
+
});
|
|
4110
|
+
server.registerTool("list_payments", {
|
|
4111
|
+
description: "List payment history with pagination",
|
|
4112
|
+
inputSchema: {
|
|
4113
|
+
page: z.number().optional().describe("Page number"),
|
|
4114
|
+
},
|
|
4115
|
+
}, async ({ page }) => {
|
|
4116
|
+
const query = page ? `?page=${page}` : "";
|
|
4117
|
+
const { data } = await apiRequest("GET", `/account/history/payments${query}`);
|
|
4118
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
642
4119
|
});
|
|
643
4120
|
// --- History ---
|
|
644
4121
|
server.registerTool("get_login_history", {
|
|
@@ -655,13 +4132,28 @@ server.registerTool("get_management_history", {
|
|
|
655
4132
|
const { data } = await apiRequest("GET", "/account/history/management");
|
|
656
4133
|
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
657
4134
|
});
|
|
4135
|
+
server.registerTool("get_usage_statements", {
|
|
4136
|
+
description: "Get itemized usage-billing statements for the account (per-period totals by family, e.g. Firecracker Functions usage, VDS snapshots, AI premium). This is where metered/usage-based charges show up, separate from invoices. Paginated.",
|
|
4137
|
+
inputSchema: {
|
|
4138
|
+
page: z
|
|
4139
|
+
.number()
|
|
4140
|
+
.int()
|
|
4141
|
+
.positive()
|
|
4142
|
+
.optional()
|
|
4143
|
+
.describe("Page number for pagination (default 1)"),
|
|
4144
|
+
},
|
|
4145
|
+
}, async ({ page }) => {
|
|
4146
|
+
const query = page !== undefined ? `?page=${page}` : "";
|
|
4147
|
+
const { data } = await apiRequest("GET", `/account/history/usage-statements${query}`);
|
|
4148
|
+
return { content: [{ type: "text", text: formatJson(data) }] };
|
|
4149
|
+
});
|
|
658
4150
|
// --- Public ---
|
|
659
4151
|
server.registerTool("get_pricing", {
|
|
660
4152
|
description: "Get public pricing for a service type",
|
|
661
4153
|
inputSchema: {
|
|
662
4154
|
type: z
|
|
663
|
-
.enum(["vps", "vds", "ds", "
|
|
664
|
-
.describe("Service type"),
|
|
4155
|
+
.enum(["vps", "vds", "ds", "firecracker"])
|
|
4156
|
+
.describe("Service type: vps, vds, ds, or firecracker"),
|
|
665
4157
|
},
|
|
666
4158
|
}, async ({ type }) => {
|
|
667
4159
|
const { data } = await apiRequest("GET", `/public/prices/${type}/plans`);
|