vpsnet-mcp 2.3.12 → 2.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,8 @@ The remote profile also includes scoped DNS, domain and certificate management,
25
25
  The remote connector uses `https://mcp.vpsnet.com/mcp` and customer consent through
26
26
  VPSnet. See [Remote AI connections](docs/REMOTE_MCP.md) for connection setup,
27
27
  permissions and supported operations.
28
+ For expired or revoked authorization and unavailable consent choices, see
29
+ [reconnecting and requested permissions](docs/REMOTE_MCP.md#reconnect-after-losing-authorization).
28
30
 
29
31
  The distributable [plugin README](plugin/README.md), manifests and skills are in English.
30
32
  See [directory readiness](docs/DIRECTORY_READINESS.md) for package validation and
@@ -76,7 +76,7 @@ const policy = `${project}/networks/{network_id}/security-policy`;
76
76
  const address = `${N}/public-addresses/{address_id}`;
77
77
  const binding = `${N}/public-bindings/{binding_id}`;
78
78
  const scope = "Requires explicit networking:read for reads or networking:manage for changes; full/read defaults do not grant networking. The backend enforces ownership, feature availability and service 2FA locks.";
79
- const writes = "Read current capabilities and owned resource state first; obtain approval for the exact change. An accepted operation is pending, not proof of connectivity. Preserve reason_code and poll the returned operation/resource. On transport uncertainty inspect state and reuse only the original key and unchanged request; never automatically retry.";
79
+ const writes = "Read current capabilities and owned resource state first; obtain approval for the exact change. An accepted operation is pending, not proof of connectivity. Poll the returned operation/resource on its in_progress flag, waiting next_check_seconds between reads: while in_progress is true its reason_code is what is awaited (reason_kind waiting, e.g. a queued or pending reason, or a control plane that has not answered yet), never an error; only in_progress false with reason_kind failure is a failure. Preserve reason_code. On transport uncertainty inspect state and reuse only the original key and unchanged request; never automatically retry.";
80
80
  const paid = "Requires full API access, networking:manage, enabled paid operations, networking:order and spend caps. Choose a supported live catalog tier and its documented request kind; preserve the exact quote; disclose its gross_amount in EUR before user approval. The same Idempotency-Key is required for quote and confirmation. API-key checkout supports the advertised account-balance payment method only. Payment acceptance is not resource readiness.";
81
81
  function tool(name, method, path, description, fields = {}, options = {}) {
82
82
  const pathFields = [...path.matchAll(/\{([^}]+)\}/g)].map(match => match[1]);
@@ -104,7 +104,7 @@ export const networkingTools = [
104
104
  tool("get_networking_overview", "GET", `${N}/overview`, "Read owned networks, quota and operation snapshot. operations_has_more signals a bounded backend feed."),
105
105
  tool("get_networking_topology", "GET", `${N}/topology`, "Read the account topology with network-scoped identities. Optional metrics=current requests measured traffic.", { metrics: z.literal("current").optional() }, { query: ["metrics"] }),
106
106
  tool("list_networking_operations", "GET", `${N}/operations`, "Read recent network operations and operations_has_more. This endpoint has no pagination parameter."),
107
- tool("get_networking_operation", "GET", `${N}/operations/{operation_id}`, "Read an accepted network or port operation only. A successful network_graph_created operation permits checking network.port_intent_ready for the first attachment; it does not prove guest connectivity. Security-policy, appliance and public-binding operation IDs belong to different stores. Poll security policies with get_network_security_policy, appliances with get_network_appliance_operation, public bindings with get_network_public_binding, and DNS changes through get_private_network.settings.dns_change."),
107
+ tool("get_networking_operation", "GET", `${N}/operations/{operation_id}`, "Read an accepted network or port operation only. Poll on in_progress (true until succeeded, failed or cancelled) and wait next_check_seconds between reads; reason_kind says what reason_code is: waiting (progress, including queued, pending and control-plane-not-yet-answered reasons - not an error), failure or outcome. A port reserve (the operation an attach returns) names its activation_operation_id once the activation that finishes the attachment exists; the completed history line names both ids. A successful network_graph_created operation permits checking network.port_intent_ready for the first attachment; it does not prove guest connectivity. Security-policy, appliance and public-binding operation IDs belong to different stores. Poll security policies with get_network_security_policy, appliances with get_network_appliance_operation, public bindings with get_network_public_binding, and DNS changes through get_private_network.settings.dns_change."),
108
108
  tool("list_private_networks", "GET", `${N}/networks`, "List owned private networks, including deletion awaiting verified cleanup. An empty list is a starting state for creating an approved network, not evidence that existing servers cannot use private networking."),
109
109
  tool("get_private_network", "GET", networks, "Read one owned network including DHCP reservations, DNS change progress, generation and project_id."),
110
110
  tool("create_private_network", "POST", `${N}/networks`, "Create an approved private network for existing services when no suitable owned network exists. A false new-order private_network.available or order_availability result alone does not block this route; honor this endpoint's own quota and feature refusal. Every subnet is unique on the whole platform and comes from 100.64.0.0/10. Omit subnet and the platform allocates a free block of subnet_prefix_length (28..24, default 26 = 64 addresses; capabilities.private_network.subnet_sizes lists each size with its server count and suggested block); or name an aligned /28 through /24 inside 100.64.0.0/10 that no network holds (409 subnet_in_use), never both. Creation is asynchronous: poll its operation and get_private_network until network.port_intent_ready is true, then check attachment candidates. A successful network_graph_created operation and provisioning_status=configured describe the graph stage; the first guest has not yet qualified connectivity. Never wait for applied_generation to equal generation before the first attachment. Creation does not itself attach a server or buy a public address.", { name, subnet: z.object({ cidr: z.string().regex(/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])(?:\.\d{1,3}){2}\/(?:2[4-8])$/) }).strict().optional(), subnet_prefix_length: z.union([z.literal(24), z.literal(25), z.literal(26), z.literal(27), z.literal(28)]).optional().describe("Size of the allocated subnet when subnet is omitted; default 26"), settings: settings.optional(), default_policy: z.enum(["accept", "deny"]).optional() }, { idempotent: true }),
@@ -115,7 +115,7 @@ export const networkingTools = [
115
115
  tool("list_private_network_ports", "GET", `${networks}/ports`, "Read owned network ports, their fixed DHCP addresses, generations and attachment states. Reserved is not connected."),
116
116
  tool("list_service_private_ports", "GET", `${N}/services/{service_id}/ports`, "Read private ports and order attachment recovery state for one owned service order number."),
117
117
  tool("list_network_attachment_candidates", "GET", `${networks}/attachment-candidates`, "For a network with port_intent_ready=true, check each existing service's attachment eligibility and attachment_mode; service_id selects one owned service. Follow next_cursor using after while candidates_has_more is true. Use this per-service result and its reason_code for existing-VM attachment, not new-server order_availability. Do not attach a service the backend marks ineligible.", { after: z.string().optional(), service_id: serviceId.optional() }, { query: ["after", "service_id"] }),
118
- tool("attach_private_network_port", "POST", `${networks}/ports`, "Attach an eligible existing owned service to a private network with port_intent_ready=true; omit ipv4 for automatic allocation or pass a fixed usable address. Check list_network_attachment_candidates for this network and service first, including pagination and attachment_mode. Set restart=true only after the user approves a required restart. An accepted attach is pending: poll its operation and verify the final port and service attachment state. Honor this endpoint's own refusal and reason_code.", { service_id: serviceId, ipv4: ipv4.optional(), restart }, { idempotent: true }),
118
+ tool("attach_private_network_port", "POST", `${networks}/ports`, "Attach an eligible existing owned service to a private network with port_intent_ready=true; omit ipv4 for automatic allocation or pass a fixed usable address. Check list_network_attachment_candidates for this network and service first, including pagination and attachment_mode. Set restart=true only after the user approves a required restart. An accepted attach is pending: it returns the port's reserve operation (queued with private_port_apply_pending; reserve, activate and detach take 13-81 s through the host) - poll it with get_networking_operation on in_progress, read its activation_operation_id once the activation that finishes the attachment exists, and verify the final port and service attachment state. Honor this endpoint's own refusal and reason_code.", { service_id: serviceId, ipv4: ipv4.optional(), restart }, { idempotent: true }),
119
119
  tool("detach_private_network_port", "DELETE", `${networks}/ports/{port_id}`, "Detach the owned server port. Use the PORT generation and confirm any required restart.", { expected_generation: generation, restart }, { idempotent: true, destructive: true }),
120
120
  tool("retry_private_network_port", "POST", `${networks}/ports/{port_id}/retry`, "Retry the failed current port operation with the PORT generation and any required restart approval. Inspect the failed operation: a detach retry can remove the server's private connectivity.", { expected_generation: generation, restart }, { idempotent: true, destructive: true }),
121
121
  tool("get_network_vpn_summary", "GET", `${networks}/vpn`, "Read the network's VPN summary, including truthful absence. Native site-to-site is not supported by this tool set."),
@@ -135,7 +135,7 @@ export const networkingTools = [
135
135
  tool("get_network_appliance_capabilities", "GET", `${A}/capabilities`, "Read available load balancer providers/tiers, public qualification, VPN capabilities and quota. Tier availability and prices are returned by the backend, never hardcoded."),
136
136
  tool("list_network_appliances", "GET", A, "Read owned load balancers and VPN gateways with one stream cursor."),
137
137
  tool("get_network_appliance_billing", "GET", `${A}/billing`, "Read current customer-visible recurring appliance lines and currency."),
138
- tool("get_network_appliance_operation", "GET", `${A}/operations/{operation_id}`, "Poll an accepted appliance operation. Creation succeeds only when its actual resource state and health confirm readiness."),
138
+ tool("get_network_appliance_operation", "GET", `${A}/operations/{operation_id}`, "Poll an accepted appliance operation on in_progress (true until succeeded, failed or cancelled), waiting next_check_seconds between reads (a load balancer or VPN change settles in a few seconds). A fresh change is queued with appliance_dispatch_pending and then waiting_dependency while the control plane answers; reason_kind waiting marks every such reason as progress, not an error. Creation succeeds only when its actual resource state and health confirm readiness."),
139
139
  tool("list_network_load_balancers", "GET", `${A}/load-balancers`, "List private and public load balancers and customer-safe health/qualification state."),
140
140
  tool("get_network_load_balancer", "GET", lb, "Read one owned load balancer, listeners, members, generation and health. A pending or unqualified public endpoint is not ready."),
141
141
  tool("estimate_network_load_balancer", "POST", `${A}/load-balancers/quote`, "Read a technical load balancer estimate for this exact configuration. This does not issue a paid purchase token; buy through quote_network_product and order_network_product. Legacy POST /appliances/load-balancers refuses unpaid creation.", loadBalancerSpec, { readOnly: true }),
@@ -208,6 +208,29 @@ export function networkingRequest(definition, args) {
208
208
  };
209
209
  return { method: definition.method, path, body, headers, readOnly: definition.options.readOnly === true };
210
210
  }
211
+ /**
212
+ * One line a client can act on without knowing the reason vocabulary (PROD QA 2026-10-14: an AI client read the
213
+ * waiting reason of a fresh operation as an error and stopped). Reads the operation of a receipt or of an
214
+ * operation read; null when the result carries no operation with a progress label.
215
+ */
216
+ export function networkingProgressHint(data) {
217
+ if (!data || typeof data !== "object" || Array.isArray(data))
218
+ return null;
219
+ const value = data;
220
+ const operation = value.operation && typeof value.operation === "object" ? value.operation
221
+ : typeof value.in_progress === "boolean" ? value : null;
222
+ if (!operation || typeof operation.in_progress !== "boolean")
223
+ return null;
224
+ const reason = typeof operation.reason_code === "string" && operation.reason_code !== "" ? operation.reason_code : "none";
225
+ if (operation.in_progress) {
226
+ const wait = Number.isInteger(operation.next_check_seconds) ? `${operation.next_check_seconds} s` : "a few seconds";
227
+ return `Operation ${operation.id ?? ""} in progress (state=${operation.state}, reason_code=${reason}, reason_kind=${operation.reason_kind ?? "waiting"}): this is progress, not an error. Poll the follow-up read again in ${wait}; do not repeat the change.`;
228
+ }
229
+ if (operation.reason_kind === "failure") {
230
+ return `Operation ${operation.id ?? ""} failed (state=${operation.state}, reason_code=${reason}). Do not retry automatically; report the reason_code.`;
231
+ }
232
+ return `Operation ${operation.id ?? ""} finished (state=${operation.state}, reason_code=${reason}).`;
233
+ }
211
234
  /** Read-only recovery targets. IDs are copied from validated inputs or owned API results, never guessed. */
212
235
  export function networkingFollowUpReads(definition, args, data) {
213
236
  if (!data || typeof data !== "object" || Array.isArray(data))
@@ -290,8 +313,10 @@ export function registerNetworkingTools(server) {
290
313
  const request = networkingRequest(definition, args);
291
314
  const result = await apiRequest(request.method, request.path, request.body, request.headers, { readOnly: request.readOnly });
292
315
  const reads = result.status < 400 ? networkingFollowUpReads(definition, args, result.data) : [];
316
+ const progress = result.status < 400 ? networkingProgressHint(result.data) : null;
293
317
  return { content: [
294
318
  { type: "text", text: formatJson(result.data) },
319
+ ...(progress ? [{ type: "text", text: progress }] : []),
295
320
  ...(reads.length ? [{ type: "text", text: `Follow-up reads (do not repeat the change): ${JSON.stringify(reads)}` }] : []),
296
321
  ], ...(result.status >= 400 ? { isError: true } : {}) };
297
322
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vpsnet-mcp",
3
- "version": "2.3.12",
3
+ "version": "2.3.13",
4
4
  "type": "module",
5
5
  "description": "MCP server for managing VPSnet.com services, applications, DNS, domains, billing, and server lifecycle operations",
6
6
  "bin": {