vpsnet-mcp 2.3.14 → 2.3.16

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
@@ -40,6 +40,8 @@ endpoint and task skills. The npm package supplies the local MCP server;
40
40
  installable plugin bundles also carry the AI application's manifest and skills.
41
41
 
42
42
  Build all client bundles with `python3 scripts/package-distribution.py`.
43
+ The same build creates four [self-contained skill imports](docs/STANDALONE_SKILLS.md)
44
+ for clients that import one skill folder or ZIP at a time.
43
45
  The output includes checksums and identifies each format. Public directory
44
46
  submission materials are in [the catalog kit](distribution/catalogs/README.md).
45
47
 
@@ -337,7 +339,10 @@ Choose your environment:
337
339
  - [VS Code with GitHub Copilot](#vs-code-with-github-copilot) — Copilot agent mode
338
340
  - [Cline](#cline) / [Cursor](#cursor) / [Windsurf](#windsurf) / [Roo Code](#roo-code) / [Codex](#codex) — Other clients
339
341
 
340
- The standard config works across most MCP clients:
342
+ The following local stdio example uses the `mcpServers` configuration format
343
+ supported by several MCP clients. Use your client's native schema; VS Code's
344
+ `.vscode/mcp.json` uses `servers` instead. For customer OAuth and the hosted
345
+ connector, use [AI client setup](docs/CLIENTS.md).
341
346
 
342
347
  ```json
343
348
  {
@@ -495,7 +500,8 @@ Go to **Cursor Settings** > **MCP** > **Add new MCP Server**. Use command type w
495
500
  <details>
496
501
  <summary>Roo Code</summary>
497
502
 
498
- Open Roo Code MCP settings and add to `roo_mcp_settings.json`:
503
+ Open Roo Code MCP settings and choose **Edit Global MCP** (currently
504
+ `mcp_settings.json`), or **Edit Project MCP** for `.roo/mcp.json`:
499
505
 
500
506
  ```json
501
507
  {
@@ -516,24 +522,74 @@ Open Roo Code MCP settings and add to `roo_mcp_settings.json`:
516
522
  </details>
517
523
 
518
524
  <details>
519
- <summary>VS Code with GitHub Copilot</summary>
525
+ <summary id="vs-code-with-github-copilot">VS Code with GitHub Copilot</summary>
520
526
 
521
- Install using the VS Code CLI:
527
+ For the hosted customer OAuth connection, run **MCP: Add Server** and enter
528
+ `https://mcp.vpsnet.com/mcp`, or merge this native VS Code entry into
529
+ `.vscode/mcp.json`, preserving other servers:
530
+
531
+ ```json
532
+ {
533
+ "servers": {
534
+ "vpsnet": {
535
+ "type": "http",
536
+ "url": "https://mcp.vpsnet.com/mcp"
537
+ }
538
+ }
539
+ }
540
+ ```
541
+
542
+ Complete the browser sign-in and consent flow when requested. Copilot's portable
543
+ workspace `.mcp.json` and user `~/.copilot/mcp-config.json` use a top-level
544
+ `mcpServers` object instead. These formats are distinct from the Claude Code
545
+ extension's configuration.
546
+
547
+ For the separate local stdio package with your own VPSnet API key, install using
548
+ the VS Code CLI:
522
549
 
523
550
  ```bash
524
551
  code --add-mcp '{"name":"vpsnet","command":"npx","args":["-y","vpsnet-mcp"],"env":{"VPSNET_API_KEY":"your_api_key_here"}}'
525
552
  ```
526
553
 
527
- Or add to your VS Code MCP config manually using the standard config above.
554
+ For manual native VS Code configuration, place that local server's `command`,
555
+ `args` and private `env` under `servers.vpsnet`, rather than copying the generic
556
+ `mcpServers` wrapper. VS Code's **MCP: Reset Cached Tools** refreshes a stale
557
+ tool catalog. See [official MCP configuration](https://code.visualstudio.com/docs/agents/reference/mcp-configuration).
528
558
 
529
559
  > This is for **GitHub Copilot** agent mode in VS Code. For the **Claude Code** extension, see the [Claude Code for VS Code Extension](#claude-code-for-vs-code-extension) section.
530
560
 
531
561
  </details>
532
562
 
533
563
  <details>
534
- <summary>Windsurf</summary>
564
+ <summary id="windsurf">Windsurf / Devin Desktop</summary>
565
+
566
+ Current official Windsurf documentation redirects to Devin Desktop. Choose the
567
+ agent you are using: legacy Cascade and the current Devin Local agent have
568
+ different MCP setup paths.
569
+
570
+ For Devin Local, add and authenticate the hosted connector with the Devin CLI:
571
+
572
+ ```sh
573
+ devin mcp add vpsnet https://mcp.vpsnet.com/mcp
574
+ devin mcp login vpsnet --scopes services:read
575
+ ```
576
+
577
+ This requests a read baseline. Request the documented scopes required for other
578
+ tasks through a new authorization flow before approving them. If authorization
579
+ is lost, use the Desktop **Authenticate** action, or `devin mcp logout vpsnet`
580
+ followed by the login command. Current CLI versions use dedicated
581
+ `.devin/mcp_config.json`, `.devin/mcp_config.local.json` or user
582
+ `~/.config/devin/mcp_config.json` files.
583
+
584
+ For legacy Cascade, open the panel's **Actions → Open MCP config file** and merge
585
+ a server under `mcpServers` with `serverUrl: "https://mcp.vpsnet.com/mcp"`.
586
+ Complete its OAuth flow. Cascade has a 100-tool total limit, so inspect the
587
+ enabled tools. The local stdio example above is a separate API-key connection.
535
588
 
536
- Follow the [Windsurf MCP documentation](https://docs.windsurf.com/windsurf/mcp). Use the standard config above.
589
+ Follow the official [Devin Local MCP guide](https://docs.devin.ai/cli/extensibility/mcp/configuration)
590
+ or [legacy Cascade guide](https://docs.devin.ai/desktop/cascade/mcp) for your
591
+ surface. Client authentication and VPSnet compatibility still need verification
592
+ on the actual client version.
537
593
 
538
594
  </details>
539
595
 
@@ -615,7 +671,7 @@ everything else.
615
671
  | Tool | Description | Scope | Paid |
616
672
  |------|-------------|-------|------|
617
673
  | `list_services` | List all active VPS services | `services:read` | free |
618
- | `get_service` | Get detailed info for a service | `services:read` | free |
674
+ | `get_service` | Read service details and the next readiness check; keep provisioning, guest access and application health separate | `services:read` | free |
619
675
  | `get_service_graphs` | Get one graph series: metric (cpu, ram, ssd, net, io, …) and period (5m … 1y) | `services:read` | free |
620
676
  | `get_event` | Read an async action's state by its `event` id or `noty` UUID | `services:read` or `account:read` | free |
621
677
  | `wait_for_event` | Poll an async action until completed or error (or a timeout) | `services:read` or `account:read` | free |
@@ -949,7 +1005,7 @@ enabled.
949
1005
  ### Guest Agent
950
1006
  | Tool | Description | Scope | Paid |
951
1007
  |------|-------------|-------|------|
952
- | `get_guest_agent_status` | Check QEMU guest-agent availability on a Cloud VPS | `services:read` | free |
1008
+ | `get_guest_agent_status` | Read guest-agent availability on Cloud VPS or Firecracker; this does not establish application readiness | `services:read` | free |
953
1009
 
954
1010
  ### History
955
1011
  | Tool | Description | Scope | Paid |
package/build/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  import { installToolInputSchemas } from "./tool-schema.js";
3
3
  import { registerOrderTools } from "./remote-orders.js";
4
4
  import { registerRemoteTools } from "./remote-tools.js";
5
+ import { registerReadOnlyContext } from "./read-only-context.js";
6
+ import { purchaseReadiness, serviceReadiness, serviceResultOutput } from "./service-readiness.js";
5
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
9
  import { pathToFileURL } from "node:url";
@@ -57,7 +59,7 @@ export function createVpsnetServer(options = {}) {
57
59
  "Resources is an array of numeric resource value IDs from get_order_options, e.g. [901, 907].",
58
60
  "rootPassword rules: 6-40 chars, alphanumeric only, MUST contain uppercase + lowercase + digit. Example: 'MyPass123'.",
59
61
  "sshKey and rootPassword are mutually exclusive — provide one or the other (or neither for auto-generated password).",
60
- "After placing an order, wait 10-30 seconds before attempting SSH — the VPS needs time to boot and start SSH daemon.",
62
+ "A paid receipt is not a running server. Read get_service for the original order and follow follow_up: pending activation, finalization and capacity waits require polling the same order. About 10 seconds is a suggested polling interval, never a boot ETA. Check at most 6 times before a progress update; if still pending, retain the key and order number for a later check. Do not reorder, repay, restart or promise a background callback. A running VM, guest access and a healthy application are separate outcomes; KVM and Windows setup may take longer and must be observed.",
61
63
  "deploy_ssh_key is also async — wait 15-30 seconds after deploying a key before attempting SSH to any VPS (not just new ones).",
62
64
  "",
63
65
  "## Deployment capabilities (unordered)",
@@ -347,13 +349,15 @@ export function createVpsnetServer(options = {}) {
347
349
  return { content: [{ type: "text", text: formatJson(data) }] };
348
350
  });
349
351
  server.registerTool("get_service", {
350
- description: "Get detailed info for a service by order number. stateProcess.waitingReason=public_ip_inventory means the paid CREATE awaits public IP inventory and resumes automatically; poll this same service without a new order, payment or manual retry. stateProcess.attention=true requires operator attention. The legacy stateProccess alias carries the same process data. 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.",
352
+ description: "Get detailed info and follow_up readiness guidance for the original service order. Payment, VM provisioning, guest-agent availability and application health are separate. Pending activation, finalizing or active stateProcess means keep polling this same order; about 10 seconds is an interval, not an ETA. Check at most 6 times before a progress update, then retain the order for the next check. stateProcess.waitingReason=public_ip_inventory resumes automatically; never reorder, pay again or manually restart. stateProcess.attention=true requires operator attention. The legacy stateProccess alias carries the same process data. Unknown guest-agent status alone does not prove the VM is down. Resource-usage rows with available=false contain compatibility placeholders, not measurements. Requires services:read when called with an API key.",
351
353
  inputSchema: {
352
354
  orderNo: serviceOrderNoSchema,
353
355
  },
356
+ outputSchema: serviceResultOutput,
354
357
  }, async ({ orderNo }) => {
355
- const { data } = await apiRequest("GET", `/account/services/${encodeURIComponent(orderNo)}`);
356
- return { content: [{ type: "text", text: formatJson(data) }] };
358
+ const { status, data } = await apiRequest("GET", `/account/services/${encodeURIComponent(orderNo)}`);
359
+ const output = serviceReadiness(data, status, orderNo, options.remoteScopes !== undefined);
360
+ return { content: [{ type: "text", text: formatJson(output) }], structuredContent: output };
357
361
  });
358
362
  server.registerTool("get_service_graphs", {
359
363
  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.",
@@ -2020,6 +2024,7 @@ export function createVpsnetServer(options = {}) {
2020
2024
  "Payment object for balance: { payment: 1, successUrl: '', cancelUrl: '' }.",
2021
2025
  "First call returns confirmationRequired and an exact gross EUR quote without charging. Show the amount and billing period, obtain explicit approval, then call again with the unchanged order configuration and idempotencyKey, returned quoteToken, expected_total_eur and acknowledge_charge=true. The API key must have paid scope/caps enabled. Never replace the key after an uncertain payment; inspect that original attempt in the VPSnet panel. Quotes are retained in this MCP process; after a restart inspect the original attempt in the panel rather than submitting another order.",
2022
2026
  "Payment acceptance is not server readiness. Poll get_service for the returned order number. stateProcess.waitingReason=public_ip_inventory resumes the same paid CREATE automatically when inventory returns; do not place or pay for another order. stateProcess.attention=true needs operator attention.",
2027
+ "Follow follow_up for the same service. Poll about every 10 seconds, never treating that interval as a boot ETA; after at most 6 checks report pending progress and retain the original key/order. Do not automatically restart or promise a completion callback. Running VM, usable guest access and application health are separate checks.",
2023
2028
  "Resources: array of numeric resource value IDs from get_order_options, e.g. [901, 907].",
2024
2029
  "rootPassword: 6-40 chars, alphanumeric, must contain uppercase + lowercase + digit. Example: 'MyPass123'.",
2025
2030
  "sshKey and rootPassword are mutually exclusive — provide one or the other.",
@@ -2103,7 +2108,8 @@ export function createVpsnetServer(options = {}) {
2103
2108
  confirmationRequired: true, idempotencyKey,
2104
2109
  message: "The original quote is no longer valid. The API refused confirmation before charging. Inspect this original attempt in the VPSnet panel and obtain approval of any changed price or configuration; do not retry an uncertain purchase with a new key." }) }] };
2105
2110
  }
2106
- return { ...(status >= 400 ? { isError: true } : {}), content: [{ type: "text", text: formatJson(data) }] };
2111
+ const output = purchaseReadiness(data, status, idempotencyKey, true, false);
2112
+ return { ...(status >= 400 ? { isError: true } : {}), content: [{ type: "text", text: formatJson(output) }], structuredContent: output };
2107
2113
  }
2108
2114
  catch {
2109
2115
  return refusal("purchaseOutcomeUnknown", "The confirmation outcome is unknown. Inspect this original attempt in the VPSnet panel. Do not submit another order or use a new key.");
@@ -3840,7 +3846,7 @@ export function createVpsnetServer(options = {}) {
3840
3846
  };
3841
3847
  });
3842
3848
  server.registerTool("get_guest_agent_status", {
3843
- 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.",
3849
+ description: "Read guest-agent availability for Cloud VPS (KVM/VDS) or Firecracker. KVM probes its QEMU agent; Firecracker reads worker-reported health. This is separate from VM provisioning and application health. Unknown is advisory, not proof the server failed; unavailable gates only operations that need the agent. Delegated Linux tools do not provide Windows PowerShell or RDP verification. Requires services:read when called with an API key.",
3844
3850
  inputSchema: {
3845
3851
  orderNo: serviceOrderNoSchema,
3846
3852
  },
@@ -4639,7 +4645,8 @@ export function createVpsnetServer(options = {}) {
4639
4645
  if (options.remoteScopes)
4640
4646
  registerRemoteTools(server);
4641
4647
  if (options.remoteProfile === "orders")
4642
- registerOrderTools(server);
4648
+ registerOrderTools(server, options.remoteScopes);
4649
+ registerReadOnlyContext(server);
4643
4650
  return server;
4644
4651
  }
4645
4652
  // --- Start server ---
@@ -0,0 +1,111 @@
1
+ import { McpError, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2
+ import { z } from "zod";
3
+ import { scopeCatalog } from "./scope-catalog.js";
4
+ // Packaged, static guidance only: no filesystem paths, customer data or API calls.
5
+ const guides = [
6
+ {
7
+ name: "connection-access",
8
+ title: "VPSnet connection and permissions",
9
+ description: "Understand discovery, permissions and safe read-only use.",
10
+ mimeType: "text/markdown",
11
+ text: [
12
+ "# Connection and permissions",
13
+ "Discover tools with tools/list; their descriptions, input schemas and annotations describe the current connection. Discovery is not permission to execute every listed tool.",
14
+ "Hosted access uses OAuth. Local stdio access uses a management API key. Never paste credentials into prompts or resources. Use the client's supported authorization flow.",
15
+ "Use get_connected_account when available to inspect the current grant. Resource visibility and allowed services are enforced by the backend. Missing permission requires the user's explicit authorization; never auto-grant or silently broaden access.",
16
+ "These resources contain static guidance, not current account data. Prompts prepare read-only workflows and never execute tools themselves. A client may choose whether to expose resources or prompts in its interface.",
17
+ "Honor the user's existing explicit authorization. Explain the target and effect and obtain approval when an action extends that authorization, destroys unrelated data or adds unapproved charges or exposure. A granted OAuth scope alone does not authorize expanding the task. Functions invocation can incur charges; inspecting an existing invocation does not require starting another one.",
18
+ ].join("\n\n"),
19
+ },
20
+ {
21
+ name: "async-operations",
22
+ title: "VPSnet asynchronous operation progress",
23
+ description: "Follow an existing operation without repeating a change.",
24
+ mimeType: "text/markdown",
25
+ text: [
26
+ "# Following existing operations",
27
+ "Keep the original service identifier, operation identifier and idempotency key. An accepted receipt is not proof that provisioning, guest boot or application startup has finished. Do not repeat a change or create another order because a response is slow.",
28
+ "For networking, follow the read tools named in the receipt. Poll get_networking_operation for network/port operations and get_network_appliance_operation for appliance operations; other operation stores have their own documented read tools. in_progress=true and reason_kind=waiting indicate progress, including waiting_dependency or a control-plane-not-yet-answered reason. They are not an MCP tool failure.",
29
+ "Respect next_check_seconds when provided. If absent, suggest a 10-second interval as polling guidance, not an ETA. Bound a monitoring pass to six reads, then return a pending update containing the identifier and next read needed so the user can resume. Never promise a callback or push notification from the stateless HTTP relay.",
30
+ "When in_progress=false, inspect the terminal state and reason_kind. Report failure or cancellation honestly and do not retry automatically. HTTP or authorization errors remain errors even while an operation is pending.",
31
+ "Read get_service for service state, get_event for an existing service event, and get_guest_agent_status when available for guest-agent evidence. VM provisioned/running, OS or guest agent ready, and application healthy are separate observations; absence of an agent does not prove the OS failed. Use application health reads for application readiness. Firecracker, KVM and Windows can take different amounts of time; no fixed completion time is guaranteed.",
32
+ ].join("\n\n"),
33
+ },
34
+ {
35
+ name: "read-only-workflows",
36
+ title: "VPSnet read-only diagnostic workflows",
37
+ description: "Choose existing read tools and report evidence with its limits.",
38
+ mimeType: "text/markdown",
39
+ text: [
40
+ "# Read-only diagnostics",
41
+ "Start with tools/list and check the current input schema. Use only tools whose annotations declare readOnlyHint=true for a read-only diagnostic pass. Some tools named inspect, discover or browse can enqueue durable work and must not be assumed read-only.",
42
+ "Service: list_services, then get_service, get_service_history and get_guest_agent_status where available and permitted. Identify the exact service before interpreting its state.",
43
+ "Networking: read get_networking_capabilities and the relevant network, topology, port or existing operation. A successful control-plane operation alone does not prove guest connectivity.",
44
+ "Applications: list_service_applications, then read get_application_installation, get_application_health and bounded events or logs for the selected installation. Redact credentials and personal data from quoted logs. Do not run install, restart, repair or reconcile as part of diagnosis.",
45
+ "Functions: use permitted existing invocation/history reads. list_functions and get_function may reconcile saved trace state; their GET method and functions:read scope do not prove they are side-effect free. Do not use those tools for a strictly side-effect-free review unless the user already authorized that reconciliation. Check current annotations and report limitations. Do not invoke a function to check it; execution is a separate user-authorized action with possible usage charges.",
46
+ "Report observed facts, unresolved questions and the smallest proposed next step. A permission denial, missing telemetry or pending operation is not proof that a service is broken.",
47
+ ].join("\n\n"),
48
+ },
49
+ {
50
+ name: "oauth-scopes",
51
+ title: "VPSnet OAuth scope catalog",
52
+ description: "Canonical nonlegacy scope names, descriptions, implications and billing categories; not the connection's grants.",
53
+ mimeType: "application/json",
54
+ text: JSON.stringify({
55
+ kind: "scope_catalog",
56
+ grants: false,
57
+ note: "Static supported-scope documentation, not granted permissions. Discovery profile and consent determine availability. Inspect get_connected_account when available for this connection's actual grant. Legacy scopes are omitted.",
58
+ scopes: Object.entries(scopeCatalog).filter(([, entry]) => !entry.legacy)
59
+ .map(([name, entry]) => ({ name, ...entry })),
60
+ }, null, 2),
61
+ },
62
+ ];
63
+ const workflows = [
64
+ ["review-service", "Review service readiness", "Review one existing service using read-only evidence.", "Read list_services if an identifier is needed, then get_service and permitted history/guest-agent reads. Separate provisioning, OS/guest-agent readiness and application readiness. Do not restart, reinstall or create another service."],
65
+ ["review-network", "Review network progress", "Inspect existing networking state and operation progress.", "Read get_networking_capabilities and the selected network/port or existing operation using the receipt's follow-up read. Treat in_progress=true with reason_kind=waiting as progress; respect next_check_seconds and return a resumable pending update after bounded polling. Do not attach, retry, reconfigure or create resources."],
66
+ ["review-application", "Review application health", "Inspect an existing application's health without changing it.", "Read list_service_applications if needed, then the selected installation, health and bounded events/logs. Redact secrets from logs. Report container/application evidence separately from VM readiness. Do not install, restart, repair, reconcile or change access."],
67
+ ["review-function", "Review function history", "Inspect prior function invocations without executing a function.", "Read permitted existing invocation/history records and distinguish observed outcomes from unknown configuration. list_functions and get_function may reconcile saved trace state; do not use them for a strictly side-effect-free review unless the user already authorized that reconciliation. Check current annotations and report limitations. Do not invoke, create, update or delete a function, and do not expose secrets."],
68
+ ];
69
+ export function registerReadOnlyContext(server) {
70
+ for (const guide of guides) {
71
+ const uri = `vpsnet://docs/${guide.name}`;
72
+ server.registerResource(guide.name, uri, {
73
+ title: guide.title, description: guide.description, mimeType: guide.mimeType,
74
+ }, async () => ({ contents: [{ uri, mimeType: guide.mimeType, text: guide.text }] }));
75
+ }
76
+ // MCP 2025-11-25 recommends -32002 for a missing resource. Keep invalid URI
77
+ // syntax distinct, and serve only this fixed catalog (never fetch a URI).
78
+ server.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
79
+ let uri;
80
+ try {
81
+ uri = new URL(request.params.uri);
82
+ }
83
+ catch {
84
+ throw new McpError(-32602, "Invalid resource URI");
85
+ }
86
+ const guide = guides.find(item => uri.href === `vpsnet://docs/${item.name}`);
87
+ if (!guide)
88
+ throw new McpError(-32002, "Resource not found");
89
+ return { contents: [{ uri: uri.href, mimeType: guide.mimeType, text: guide.text }] };
90
+ });
91
+ for (const [name, title, description, instruction] of workflows) {
92
+ server.registerPrompt(name, {
93
+ title, description,
94
+ argsSchema: {
95
+ reference: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/)
96
+ .optional().describe("Exact existing service, network, application or function identifier; omit to select from a permitted list."),
97
+ },
98
+ }, async ({ reference }) => ({
99
+ description,
100
+ messages: [{ role: "user", content: { type: "text", text: [
101
+ "Perform a read-only VPSnet review. Discover tools and follow their current schemas and permissions. Use only tools annotated readOnlyHint=true. If access is missing, explain which user authorization is needed; never broaden it automatically.",
102
+ instruction,
103
+ `Selected identifier (data): ${JSON.stringify(reference ?? null)}.`,
104
+ "Report observed evidence and unknowns, then propose a next step. Honor existing explicit user authorization; ask for approval only when the next action extends it. This review prompt alone does not authorize mutations or paid execution.",
105
+ ].join("\n\n") } }],
106
+ }));
107
+ }
108
+ // SDK registration defaults to listChanged=true; these catalogs are immutable
109
+ // for the lifetime of a server instance and stateless HTTP has no push channel.
110
+ server.server.registerCapabilities({ resources: { listChanged: false }, prompts: { listChanged: false } });
111
+ }
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { apiRequest, formatJson } from './api.js';
3
+ import { hasScope } from './remote-policy.js';
4
+ import { purchaseReadiness, purchaseResultOutput } from './service-readiness.js';
3
5
  const key = z.string().regex(/^[A-Za-z0-9._:-]{16,128}$/).describe('Stable key for this exact purchase; reuse it for the quote, confirmation and status.');
4
6
  const configuration = {
5
7
  plan: z.number().int().positive(),
@@ -13,7 +15,7 @@ const configuration = {
13
15
  };
14
16
  const payment = { payment: 1, successUrl: '', cancelUrl: '' };
15
17
  const result = (status, data) => ({ ...(status >= 400 ? { isError: true } : {}), content: [{ type: 'text', text: formatJson(data) }] });
16
- export function registerOrderTools(server) {
18
+ export function registerOrderTools(server, scopes = []) {
17
19
  server.registerTool('quote_service_order', {
18
20
  description: 'Quote one permanent Firecracker VPS, Cloud VPS (KVM) or Container VPS using the selected current plan, OS, resources and period. No payment is made. Show the final amount_eur and billing period, then obtain explicit approval before confirm_service_order. Functions is a separate usage-billed product.',
19
21
  inputSchema: configuration,
@@ -23,23 +25,27 @@ export function registerOrderTools(server) {
23
25
  return result(status, data);
24
26
  });
25
27
  server.registerTool('confirm_service_order', {
26
- description: 'Purchase the exact previously quoted server from VPSnet balance after explicit customer approval of its final EUR total and billing period. Supply the unchanged quote configuration, token and key. Never automatically create a new key after an uncertain result: inspect get_purchase_result. A receipt is not a ready server. Poll get_purchase_result for the purchase outcome; get_service for the returned orderno additionally requires services:read. This connection receives access to its newly ordered server only within the permissions the customer granted.',
28
+ description: 'Purchase the exact previously quoted server from VPSnet balance after explicit customer approval of its final EUR total and billing period. Supply the unchanged quote configuration, token and key. Never automatically create a new key after an uncertain result: inspect get_purchase_result. A receipt is not a ready server. Poll get_purchase_result for the financial outcome; get_service for the returned orderno additionally requires services:read. Follow follow_up for provisioning checks, permission requirements and bounded progress updates. The suggested 10-second polling interval is not an ETA. This connection receives access to its newly ordered server only within the permissions the customer granted.',
27
29
  inputSchema: { ...configuration,
28
30
  quoteToken: z.string().min(32).max(256),
29
31
  expected_total_eur: z.number().finite().nonnegative().describe('Exact final amount_eur from the quote, shown to and approved by the customer.'),
30
32
  acknowledge_charge: z.literal(true).describe('The customer explicitly approved this exact balance charge.'),
31
33
  },
34
+ outputSchema: purchaseResultOutput,
32
35
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
33
36
  }, async (input) => {
34
37
  const { status, data } = await apiRequest('POST', '/account/mcp/orders/services/confirm', { ...input, payment }, { 'Idempotency-Key': input.idempotencyKey, 'X-Quote-Token': input.quoteToken });
35
- return result(status, data);
38
+ const output = purchaseReadiness(data, status, input.idempotencyKey, hasScope(scopes, 'services:read'));
39
+ return { ...result(status, output), structuredContent: output };
36
40
  });
37
41
  server.registerTool('get_purchase_result', {
38
- description: 'Inspect this connection’s existing purchase attempt by its original idempotency key. Pending or unknown is not a successful purchase. Do not submit a second order. A returned server orderno can be checked with get_service; a domain remains pending until its registrar status confirms registration.',
42
+ description: 'Inspect this connection’s existing financial purchase attempt by its original idempotency key. This replays a financial receipt, not live provisioning progress. Pending or unknown must not trigger a second order. Follow follow_up: a returned server orderno is checked with get_service; if services:read is missing, re-authorize this existing connection without ordering again. A domain remains pending until its registrar status confirms registration. Do not promise an idle client a completion callback.',
39
43
  inputSchema: { idempotencyKey: key },
44
+ outputSchema: purchaseResultOutput,
40
45
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
41
46
  }, async ({ idempotencyKey }) => {
42
47
  const { status, data } = await apiRequest('GET', `/account/mcp/orders/requests/${encodeURIComponent(idempotencyKey)}`);
43
- return result(status, data);
48
+ const output = purchaseReadiness(data, status, idempotencyKey, hasScope(scopes, 'services:read'));
49
+ return { ...result(status, output), structuredContent: output };
44
50
  });
45
51
  }
@@ -8,6 +8,7 @@ export const remoteInstructions = [
8
8
  "Firecracker VPS supports general-purpose and resource-intensive Linux workloads with substantial server resources and low virtualization overhead. MicroVM describes the lightweight design, not a small server limit. Consider it when KVM-specific features are unnecessary; match current plans to resource and availability requirements.",
9
9
  "Cloud VPS (KVM) offers Linux and BSD support, automatic Windows Server provisioning, three-copy Ceph NVMe storage on enterprise drives with power-loss protection (PLP), HA restart on another host after host failure, and live migration to another node for planned host upgrades, with migration back when appropriate.",
10
10
  "Use the connected VPSnet account and only the services and permissions it granted. Never request a model API key.",
11
+ "On first VPSnet use in a conversation, call get_update_info once when available. Read an installed skill's bundled plugin-version reference if accessible; otherwise omit pluginVersion rather than infer it from clientInfo.version or repository HEAD. Keep checked/dismissed state only in conversation context. If an actually observed plugin/local version is older, give one concise native update advisory and continue the task. For unknown/current versions or unavailable release facts, stay quiet unless the user asks about updates. Never force an update, change grants or treat a published runtime as proof of deployment.",
11
12
  "Tool discovery describes supported capabilities, not granted authority. Compare the scopes and selected services from get_connected_account with each tool's required scope metadata. Explain missing permissions only when relevant to the user's task. Ask the customer to approve the required permission by re-authorizing this existing VPSnet connection through the AI app's OAuth flow; never work around a denial.",
12
13
  "Inspect current capabilities and state before a change. Honor the user's existing authorization; obtain consent when a change expands the task, destroys unrelated data or adds unapproved exposure.",
13
14
  "Keep operation IDs, reason codes and idempotency keys. Accepted means pending; read the final state before claiming success.",
@@ -27,7 +28,7 @@ export function orderInstructions(scopes) {
27
28
  const products = [scopes.includes('orders:servers') ? 'purchase permanent VPS servers' : '',
28
29
  scopes.includes('orders:domains') ? 'register domains' : ''].filter(Boolean);
29
30
  const instructions = products.length
30
- ? `This VPSnet connection can ${products.join(' and ')} from the customer’s VPSnet balance within its granted permissions. Quote first, show the final gross EUR amount and period, and confirm only after explicit approval. ${scopes.includes('orders:domains') ? 'Domain registration may be non-refundable. ' : ''}Daily and monthly limits cover this connection across token refresh. Never use a new key to retry an uncertain payment; inspect get_purchase_result. Payment settings and saved-card charges are not exposed.`
31
+ ? `This VPSnet connection can ${products.join(' and ')} from the customer’s VPSnet balance within its granted permissions. Quote first, show the final gross EUR amount and period, and confirm only after explicit approval. ${scopes.includes('orders:domains') ? 'Domain registration may be non-refundable. ' : ''}Daily and monthly limits cover this connection across token refresh. Never use a new key to retry an uncertain payment; inspect get_purchase_result. A financial receipt is separate from provisioning: follow follow_up and get_service for the same returned order, requiring services:read. If missing, re-authorize this existing connection without granting it automatically or ordering again. Poll about every 10 seconds as a suggested interval, never an ETA; after at most 6 checks report pending progress and retain the original IDs. Do not restart, reorder or promise an idle AI a callback. A running VM, guest access and application health are separate checks. Payment settings and saved-card charges are not exposed.`
31
32
  : 'This VPSnet connection can inspect order plans and existing purchase results within its granted permissions. It cannot submit purchases or change payment settings.';
32
33
  return remoteInstructions.replace('This connector does not purchase capacity, change payment settings or run model inference.', instructions);
33
34
  }
@@ -118,7 +119,7 @@ const permissions = new Map([
118
119
  ["get_system_status", "authenticated"],
119
120
  ["get_faq", "authenticated"],
120
121
  ["get_pricing", "authenticated"],
121
- ["get_connected_account", "authenticated"],
122
+ ["get_connected_account", "authenticated"], ["get_update_info", "authenticated"],
122
123
  ["open_vm_session", "vm:session"], ["run_vm_command", "vm:exec"], ["read_vm_file", "vm:files:read"],
123
124
  ["write_vm_file", "vm:files:write"], ["create_vm_file", "vm:files:write"], ["inspect_vm", "vm:diagnostics"], ["get_vm_operation", "vm:session"], ["cancel_vm_operation", "vm:session"],
124
125
  ]);
@@ -159,15 +160,138 @@ const titleAcronyms = {
159
160
  function defaultToolTitle(name) {
160
161
  return name.split(/[_-]+/).filter(Boolean).map((word, index) => titleAcronyms[word.toLowerCase()] ?? (index === 0 ? word[0].toUpperCase() + word.slice(1) : word)).join(' ');
161
162
  }
163
+ // Explicit legacy stdio classifications, reviewed against handlers and backend effects.
164
+ // This only fills missing metadata; remote policy supplies every hint and remains unchanged.
165
+ // POST calculate_plan_change only computes a price; POST quotes persist an intent.
166
+ const legacyReadOnlyTools = new Set([
167
+ 'get_account',
168
+ 'get_profile',
169
+ 'list_services',
170
+ 'get_service',
171
+ 'get_service_graphs',
172
+ 'get_event',
173
+ 'wait_for_event',
174
+ 'get_service_history',
175
+ 'get_rdns',
176
+ 'get_title',
177
+ 'get_os_options',
178
+ 'get_plan_options',
179
+ 'get_plan_resources',
180
+ 'calculate_plan_change',
181
+ 'get_period_options',
182
+ 'get_order_plans',
183
+ 'get_order_options',
184
+ 'get_backup_status',
185
+ 'get_backup_history',
186
+ 'list_ssh_keys',
187
+ 'get_ssh_key',
188
+ 'list_api_keys',
189
+ 'get_api_key',
190
+ 'get_api_key_activity',
191
+ 'get_api_key_inference_usage',
192
+ 'list_domains',
193
+ 'get_domain',
194
+ 'list_domain_tlds',
195
+ 'list_domain_glue_records',
196
+ 'get_domain_parent_ds',
197
+ 'check_domain_availability',
198
+ 'get_domain_ordering_status',
199
+ 'list_domain_contacts',
200
+ 'get_registrar_lock',
201
+ 'list_service_dns_options',
202
+ 'list_firecracker_snapshots',
203
+ 'get_restore_status',
204
+ 'list_restore_points',
205
+ 'get_guest_agent_status',
206
+ 'list_function_invocations',
207
+ 'get_function_invocation',
208
+ 'list_dns_zones',
209
+ 'get_dns_zone',
210
+ 'get_dns_zone_diagnostics',
211
+ 'export_dns_zone',
212
+ 'list_dns_templates',
213
+ 'get_dnssec',
214
+ 'get_dns_service_status',
215
+ 'get_dns_zone_history',
216
+ 'list_ddns_tokens',
217
+ 'list_invoices',
218
+ 'get_invoice',
219
+ 'list_payments',
220
+ 'get_login_history',
221
+ 'get_management_history',
222
+ 'get_usage_statements',
223
+ 'get_pricing',
224
+ 'get_system_status',
225
+ 'get_faq',
226
+ ]);
227
+ const legacyWriteDestructive = new Map([
228
+ // GET also advances a pending rollback, including disk/application reconciliation.
229
+ ['list_snapshots', true],
230
+ // GET may create/repair the billing anchor and replace a Function's association.
231
+ ['list_functions', false],
232
+ ['get_function', false],
233
+ // GET reserves/reconciles a platform hostname and queues DNS/PTR/guest work.
234
+ ['get_hostname', true],
235
+ ['start_service', false],
236
+ ['stop_service', true],
237
+ ['restart_service', true],
238
+ ['console_service', false],
239
+ ['suspend_service', true],
240
+ ['resume_service', false],
241
+ ['change_hostname', true],
242
+ ['reset_hostname', true],
243
+ ['change_root_password', true],
244
+ ['flush_iptables', true],
245
+ ['change_title', true],
246
+ ['toggle_ipv6', true],
247
+ ['toggle_extra_settings', true],
248
+ ['deploy_ssh_key', true],
249
+ ['change_plan', true],
250
+ ['set_auto_renew', true],
251
+ ['renew_service', true],
252
+ ['order_service', true],
253
+ ['create_backup', true],
254
+ ['create_ssh_key', false],
255
+ ['delete_ssh_key', true],
256
+ ['create_domain_contact', true],
257
+ ['update_domain_contact', true],
258
+ ['delete_domain_contact', true],
259
+ ['quote_domain_register', false],
260
+ ['quote_domain_transfer', false],
261
+ ['quote_domain_renew', false],
262
+ ['quote_domain_restore', false],
263
+ ['set_domain_auto_renew', true],
264
+ ['create_snapshot', true],
265
+ ['rollback_snapshot', true],
266
+ ['delete_snapshot', true],
267
+ ['create_firecracker_snapshot', false],
268
+ ['rollback_firecracker_snapshot', true],
269
+ ['delete_firecracker_snapshot', true],
270
+ ['reissue_dns_zone_verification', true],
271
+ ['create_ddns_token', false],
272
+ ['revoke_ddns_token', true],
273
+ ]);
162
274
  /** Give every transport a discoverable title while retaining authored annotations. */
163
275
  export function installToolTitles(server) {
164
276
  const original = server.registerTool.bind(server);
165
277
  const register = (name, config, handler) => {
166
278
  const annotations = (config.annotations ?? {});
167
279
  const authored = [annotations.title, config.title].find(title => typeof title === 'string' && title.trim().length > 0);
280
+ const title = authored ?? defaultToolTitle(name);
281
+ const legacyHints = legacyReadOnlyTools.has(name)
282
+ ? { readOnlyHint: true, destructiveHint: false }
283
+ : legacyWriteDestructive.has(name)
284
+ ? { readOnlyHint: false, destructiveHint: legacyWriteDestructive.get(name) }
285
+ : {};
286
+ const completed = { ...annotations };
287
+ for (const [key, value] of Object.entries({ ...legacyHints, openWorldHint: externalTools.has(name) })) {
288
+ if (completed[key] === undefined)
289
+ completed[key] = value;
290
+ }
168
291
  return original(name, {
169
292
  ...config,
170
- annotations: { ...annotations, title: authored ?? defaultToolTitle(name) },
293
+ title: config.title || title,
294
+ annotations: { ...completed, title },
171
295
  }, handler);
172
296
  };
173
297
  server.registerTool = register;
@@ -212,6 +336,16 @@ const deduplicatedTools = new Set([
212
336
  // These read guest or backup state but queue durable operations, so their read scopes do
213
337
  // not imply a read-only MCP call.
214
338
  const queuedReadTools = new Set(['browse_restore_files', 'inspect_vm', 'read_vm_file', 'get_application_health', 'get_application_logs', 'discover_service_containers']);
339
+ // Intentional hosted metadata correction: these endpoints can change existing state
340
+ // even though their permission is read-scoped (or default-contact creation is additive
341
+ // only when is_default is false). No permission or handler changes accompany these hints.
342
+ const hostedReconciliationEffects = new Map([
343
+ ['list_snapshots', true],
344
+ ['get_hostname', true],
345
+ ['list_functions', false],
346
+ ['get_function', false],
347
+ ['create_domain_contact', true],
348
+ ]);
215
349
  // Keep cached aggregate calls valid, but advertise the individually scoped
216
350
  // actions to new directory clients. The SDK uses one handler for tools/list;
217
351
  // filtering its result leaves the normal tools/call dispatcher intact.
@@ -227,7 +361,14 @@ export function installRemotePolicy(server, scopes, profile = 'directory') {
227
361
  return setRequestHandler(schema, handler);
228
362
  return setRequestHandler(schema, async (...args) => {
229
363
  const response = await handler(...args);
230
- return { ...response, tools: response.tools.filter((tool) => !legacyAggregateTools.has(tool.name)) };
364
+ return { ...response, tools: response.tools
365
+ .filter((tool) => !legacyAggregateTools.has(tool.name))
366
+ // SDK registerTool preserves _meta but drops unknown top-level config
367
+ // fields. Add the canonical declaration to the emitted descriptor,
368
+ // using exactly the same scopes as its compatibility mirror.
369
+ .map((tool) => ({
370
+ ...tool, securitySchemes: tool._meta?.securitySchemes,
371
+ })) };
231
372
  });
232
373
  });
233
374
  const original = server.registerTool.bind(server);
@@ -258,6 +399,10 @@ export function installRemotePolicy(server, scopes, profile = 'directory') {
258
399
  annotations.readOnlyHint = true;
259
400
  annotations.destructiveHint = false;
260
401
  }
402
+ if (hostedReconciliationEffects.has(name)) {
403
+ annotations.readOnlyHint = false;
404
+ annotations.destructiveHint = hostedReconciliationEffects.get(name);
405
+ }
261
406
  if (deduplicatedTools.has(name))
262
407
  annotations.idempotentHint = true;
263
408
  const objectSchema = config.inputSchema instanceof z.ZodObject ? config.inputSchema : undefined;
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { apiRequest, resolveApiBase } from './api.js';
4
+ import { getUpdateInfo, updateInfoOutput } from './update-info.js';
4
5
  const resource = { orderNo: z.string().regex(/^[A-Za-z0-9-]{1,64}$/), sessionBinding: z.string().regex(/^[a-f0-9]{32}$/, 'sessionBinding must contain exactly 32 lowercase hexadecimal characters (0-9, a-f), without hyphens. For a new open_vm_session call, omit it so VPSnet creates it; otherwise reuse the returned sessionBinding.').describe('Exactly 32 lowercase hexadecimal characters (0-9, a-f), without hyphens. Copy sessionBinding from open_vm_session and reuse it for every operation and reconnect. Not a credential.') };
5
6
  const session = { ...resource, sessionId: z.string().uuid() };
6
7
  const idempotencyKey = z.string().regex(/^[A-Za-z0-9_-]{16,128}$/).describe('Stable key for this exact operation. Reuse after a lost response; never automatically replace.');
@@ -76,9 +77,24 @@ async function operationResult(orderNo, sessionBinding, operationId, cancel = fa
76
77
  return runtime(await lease(orderNo, sessionBinding, true), cancel ? 'POST' : 'GET', `/mcp/operations/${operationId}${cancel ? '/cancel' : ''}`, cancel ? {} : undefined);
77
78
  }
78
79
  export function registerRemoteTools(server) {
80
+ server.registerTool('get_update_info', {
81
+ description: 'Read public VPSnet releases and reviewed client update guidance without changing software, credentials or permissions. On first VPSnet use in a conversation, check once when available. Pass installed versions only if actually observed or user-reported; never infer them from host clientInfo.version or the gateway runtime. Unknown installed versions stay unknown. If an observed version is older, give one concise advisory and continue; otherwise stay quiet unless the user asks about updates. Remember checks and dismissals in conversation context, not customer tracking. VPSnet maintains the hosted gateway.',
82
+ inputSchema: {
83
+ client: z.string().max(64).optional().describe('Known client identifier such as codex, claude-code or gemini-cli; omit when unknown.'),
84
+ pluginVersion: z.string().max(63).optional().describe('Observed or user-reported installed VPSnet plugin version, not the host application version. Omit if unknown.'),
85
+ localMcpVersion: z.string().max(63).optional().describe('Observed or user-reported local vpsnet-mcp npm version. Omit for remote-only connections or when unknown.'),
86
+ }, outputSchema: updateInfoOutput,
87
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
88
+ }, async (args) => { const data = await getUpdateInfo(args); return { ...result(data), structuredContent: data }; });
89
+ const spendingLimit = z.union([z.number().finite().nonnegative(), z.string().regex(/^\d+(?:\.\d+)?$/)]).nullable().optional();
79
90
  server.registerTool('get_connected_account', {
80
- description: 'Identify the connected VPSnet account and its granted permissions and services.', inputSchema: {},
81
- annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, _meta: { 'openai/profile': true },
91
+ description: 'Identify the connected VPSnet account and its granted permissions, selected services and spending limits.', inputSchema: {},
92
+ outputSchema: z.object({
93
+ id: z.string().min(1), name: z.string(), scopes: z.array(z.string()), services: z.array(z.string()),
94
+ function_daily_limit_eur: spendingLimit, order_daily_limit_eur: spendingLimit, order_monthly_limit_eur: spendingLimit,
95
+ function_monthly_limit_eur: spendingLimit.describe('Stored monthly Functions limit in EUR when supplied by the backend. Null or omitted means no explicit monthly cap is reported by this grant, not unlimited usage; account limits still apply. Read only: never infer a default or expand permissions.'),
96
+ }).passthrough(),
97
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
82
98
  }, async () => { const { data } = await apiRequest('GET', '/account/mcp/profile'); return { ...result(data), structuredContent: data }; });
83
99
  server.registerTool('open_vm_session', {
84
100
  description: 'Open an authorized Linux session on a selected KVM or Firecracker service. The VPSnet guest channel works without a VM IP, SSH password or guest internet connection; choose connectionMethod="guest" for network/SSH troubleshooting or "ssh" for the independent SSH path. Auto prefers the guest channel. Requires explicit VM permission. Omit sessionBinding for a new session; VPSnet generates it and returns it with sessionId. Reuse both returned values for commands, files and reconnects. The OS user is root. Read /etc/os-release through the first operation to verify guest connectivity before choosing OS-specific commands or making changes. An empty managed-app list does not mean the server has no software.',
@@ -0,0 +1,130 @@
1
+ import { z } from 'zod';
2
+ const orderNo = z.string().regex(/^[A-Za-z0-9-]{1,64}$/);
3
+ const attemptKey = z.string().regex(/^[A-Za-z0-9._:-]{16,128}$/);
4
+ /** Advisory continuation, never an ETA, grant, background task or payment retry. */
5
+ export const serviceFollowUpSchema = z.object({
6
+ purchase_state: z.enum(['accepted', 'pending', 'unknown']).nullable(),
7
+ provisioning_state: z.enum(['pending', 'complete', 'not_running', 'needs_attention', 'failed', 'unknown']),
8
+ permission_required: z.literal('services:read').nullable(),
9
+ order_no: orderNo.nullable(),
10
+ idempotency_key: attemptKey.nullable(),
11
+ next_check: z.object({
12
+ tool: z.enum(['get_service', 'get_purchase_result']),
13
+ arguments: z.object({ orderNo: orderNo.optional(), idempotencyKey: attemptKey.optional() }),
14
+ }).nullable(),
15
+ poll_after_seconds: z.literal(10).nullable(),
16
+ max_checks_before_update: z.literal(6),
17
+ do_not_reorder: z.literal(true),
18
+ background_notification: z.literal(false),
19
+ server_running: z.boolean().nullable(),
20
+ guest_agent_available: z.boolean().nullable(),
21
+ application_ready: z.null(),
22
+ message: z.string(),
23
+ });
24
+ // Directory-only clients must not discover purchase tools through this schema.
25
+ export const serviceResultOutput = z.object({ follow_up: serviceFollowUpSchema.extend({
26
+ next_check: z.object({ tool: z.literal('get_service'), arguments: z.object({ orderNo }) }).nullable(),
27
+ }) }).passthrough();
28
+ export const purchaseResultOutput = z.object({ follow_up: serviceFollowUpSchema.optional() }).passthrough();
29
+ function object(value) {
30
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
31
+ ? value : undefined;
32
+ }
33
+ function base() {
34
+ return {
35
+ purchase_state: null, provisioning_state: 'unknown', permission_required: null,
36
+ order_no: null, idempotency_key: null, next_check: null, poll_after_seconds: null,
37
+ max_checks_before_update: 6, do_not_reorder: true, background_notification: false,
38
+ server_running: null, guest_agent_available: null, application_ready: null,
39
+ message: 'Readiness is unknown. Inspect the original order; do not place another order or restart it.',
40
+ };
41
+ }
42
+ const pendingMessage = 'Keep the same order and check again after about 10 seconds; this is a polling interval, not a completion estimate. Check at most 6 times before a progress update. If still pending, report that state and retain the order number for the next check. Do not reorder or restart. No background notification is scheduled.';
43
+ export function serviceReadiness(data, status, requestedOrderNo, remote = true) {
44
+ const response = object(data);
45
+ const follow = { ...base(), order_no: requestedOrderNo };
46
+ if (status === 403 && (response?.required_scope === 'services:read' || response?.requiredScope === 'services:read')) {
47
+ follow.permission_required = 'services:read';
48
+ follow.message = remote
49
+ ? 'Service readiness could not be read. If services:read is missing, ask the customer to approve it by re-authorizing this existing connection. Do not grant it automatically or submit another order.'
50
+ : 'Service readiness could not be read. Ask the owner to approve services:read for this management API key in the VPSnet panel, then inspect the same order. Do not change permissions automatically or submit another order.';
51
+ }
52
+ else if (status >= 200 && status < 300 && response?.success === true) {
53
+ const service = object(response.service);
54
+ const extra = object(service?.extra);
55
+ const processValue = service && 'stateProcess' in service ? service.stateProcess : service?.stateProccess;
56
+ const process = object(processValue);
57
+ const state = service?.state;
58
+ const sameOrder = service?.orderNo === requestedOrderNo;
59
+ if (service && sameOrder && typeof state === 'string') {
60
+ follow.next_check = { tool: 'get_service', arguments: { orderNo: requestedOrderNo } };
61
+ follow.guest_agent_available = typeof extra?.guestAgentAvailable === 'boolean' ? extra.guestAgentAvailable : null;
62
+ const waiting = extra?.pendingActivation === true || extra?.awaitingCapacity === true
63
+ || extra?.awaitingStock === true || service.finalizing === true || process !== undefined;
64
+ if (state === 'create_failed') {
65
+ follow.provisioning_state = 'failed';
66
+ follow.next_check = null;
67
+ follow.message = 'The service reports create_failed. Preserve its createFailure reason and original order; report the failure and any operator attention. Do not automatically order, pay, retry provisioning or restart it.';
68
+ }
69
+ else if (process?.attention === true || extra?.operatorWaiting === true
70
+ || (extra?.pendingActivation === true && extra.pendingActivationRefresh === false)) {
71
+ follow.provisioning_state = 'needs_attention';
72
+ follow.next_check = null;
73
+ follow.message = 'This service reports operator attention. Report the original order number and observed reason; do not blindly retry, reorder, pay again or restart it.';
74
+ }
75
+ else if (waiting || ['creating', 'starting', 'restarting'].includes(state)) {
76
+ follow.provisioning_state = 'pending';
77
+ follow.poll_after_seconds = 10;
78
+ follow.message = pendingMessage;
79
+ }
80
+ else if (state === 'running' && extra?.pendingActivation === false
81
+ && service.finalizing === false && processValue === null) {
82
+ follow.provisioning_state = 'complete';
83
+ follow.server_running = true;
84
+ follow.next_check = null;
85
+ follow.message = 'The service reports a running VM with provisioning and finalization clear. Guest-agent availability is separate and advisory; verify the required guest capability before using it. Application health, endpoint reachability and Windows license activation are not proved by this result.';
86
+ }
87
+ else if (['stopped', 'suspended'].includes(state)) {
88
+ follow.provisioning_state = 'not_running';
89
+ follow.server_running = false;
90
+ follow.next_check = null;
91
+ follow.message = 'The service is not running. Inspect the observed state and the original order before proposing a change; do not automatically reorder or restart.';
92
+ }
93
+ }
94
+ }
95
+ return { ...(response ?? { success: false, error: 'invalid_service_response' }), follow_up: follow };
96
+ }
97
+ /** Preserve the financial receipt, then explicitly hand off to its service. */
98
+ export function purchaseReadiness(data, status, key, canReadServices, receiptRecovery = true) {
99
+ const response = object(data) ?? { success: false, error: 'invalid_purchase_response' };
100
+ // Domain receipts belong to the registrar workflow, not service provisioning.
101
+ if (typeof response.domain_id === 'number' || object(response.domain) || typeof response.domain === 'string')
102
+ return response;
103
+ const follow = { ...base(), idempotency_key: key, purchase_state: 'unknown' };
104
+ const parsedOrder = orderNo.safeParse(response.orderno);
105
+ if (status >= 200 && status < 300 && response.success === true && parsedOrder.success) {
106
+ follow.purchase_state = 'accepted';
107
+ follow.order_no = parsedOrder.data;
108
+ follow.next_check = { tool: 'get_service', arguments: { orderNo: parsedOrder.data } };
109
+ if (!canReadServices) {
110
+ follow.permission_required = 'services:read';
111
+ follow.message = 'The financial request succeeded; provisioning is not verified. Ask the customer to approve services:read by re-authorizing this existing connection, then inspect this same order. Never grant permissions automatically, create another connection/order, pay again or restart the server.';
112
+ }
113
+ else {
114
+ follow.message = 'The financial request succeeded; this receipt is not server readiness. Call get_service with this order number now, then follow its state checks. Retain the original key and order; never pay or order again because provisioning is slow.';
115
+ }
116
+ }
117
+ else {
118
+ follow.next_check = { tool: 'get_purchase_result', arguments: { idempotencyKey: key } };
119
+ follow.poll_after_seconds = 10;
120
+ if (status === 202 && response.state === 'pending' && response.idempotencyKey === key)
121
+ follow.purchase_state = 'pending';
122
+ follow.message = 'The financial outcome is pending or unknown. Inspect get_purchase_result with this original key; do not submit another confirmation, order or key. Check at most 6 times at about 10-second intervals, then report the unresolved outcome and retain the key. A timeout is not proof of failure; no background notification is scheduled.';
123
+ if (!receiptRecovery) {
124
+ follow.next_check = null;
125
+ follow.poll_after_seconds = null;
126
+ follow.message = 'The financial outcome is unresolved. Inspect this original attempt in the VPSnet panel; local API-key ordering has no durable purchase-result tool. Retain the key and never submit another confirmation, order or key to retry an uncertain purchase.';
127
+ }
128
+ }
129
+ return { ...response, follow_up: follow };
130
+ }
@@ -7,7 +7,18 @@ function failurePayload(value) {
7
7
  const hasError = error === true
8
8
  || (typeof error === "string" && error.trim().length > 0)
9
9
  || (error !== null && typeof error === "object" && Object.keys(error).length > 0);
10
- return payload.success === false
10
+ // A validated asynchronous purchase receipt says success=false while its
11
+ // financial outcome is pending. Keep that resumable read distinct from a
12
+ // failed payment. Explicit errors and HTTP failures still take precedence.
13
+ const follow = payload.follow_up;
14
+ const next = follow?.next_check;
15
+ const purchasePending = payload.state === 'pending'
16
+ && typeof payload.idempotencyKey === 'string'
17
+ && follow?.purchase_state === 'pending'
18
+ && follow.idempotency_key === payload.idempotencyKey
19
+ && next?.tool === 'get_purchase_result'
20
+ && next.arguments?.idempotencyKey === payload.idempotencyKey;
21
+ return (payload.success === false && !purchasePending)
11
22
  || hasError
12
23
  || (typeof payload.http_status === "number" && payload.http_status >= 400)
13
24
  || payload.state === "error";
@@ -0,0 +1,135 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { z } from 'zod';
3
+ export const RELEASE_FEED_URL = 'https://raw.githubusercontent.com/bacarrdy/vpsnet-mcp/main/distribution/releases/published.json';
4
+ export const MAX_FEED_BYTES = 65536;
5
+ const stable = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
6
+ const entry = z.object({
7
+ version: z.string().max(63).regex(stable), source_commit: z.string().regex(/^[0-9a-f]{40}$/),
8
+ verified_at: z.string().max(40).datetime({ offset: true }), url: z.string().max(256),
9
+ sha256: z.string().regex(/^[0-9a-f]{64}$/), ci_run: z.number().int().positive().safe(),
10
+ ci_conclusion: z.literal('success'),
11
+ }).strict();
12
+ const feedSchema = z.object({ schema_version: z.literal(1), plugin: entry, mcp: entry }).strict();
13
+ const instructions = JSON.parse(readFileSync(new URL('../scripts/update-clients.json', import.meta.url), 'utf8'));
14
+ const runtimeVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
15
+ export function validatePublishedFeed(value) {
16
+ const feed = feedSchema.parse(value);
17
+ if (feed.plugin.url !== `https://github.com/bacarrdy/vpsnet-mcp/releases/download/plugin-${feed.plugin.version}/vpsnet-plugin-${feed.plugin.version}.zip`
18
+ || feed.mcp.url !== `https://registry.npmjs.org/vpsnet-mcp/-/vpsnet-mcp-${feed.mcp.version}.tgz`) {
19
+ throw new Error('Unexpected release artifact URL');
20
+ }
21
+ return feed;
22
+ }
23
+ /** Reject duplicate keys (including escaped aliases) before normal JSON parsing. */
24
+ export function parsePublishedFeed(text) {
25
+ const token = /\s*("(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*"|true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|[{}\[\],:])/gy;
26
+ const stack = [];
27
+ let position = 0;
28
+ while (position < text.length && text.slice(position).trim()) {
29
+ token.lastIndex = position;
30
+ const match = token.exec(text);
31
+ if (!match)
32
+ throw new Error('Invalid release JSON');
33
+ const value = match[1], frame = stack.at(-1);
34
+ if (value === '{' || value === '[')
35
+ stack.push({ object: value === '{', key: value === '{', keys: new Set() });
36
+ else if (value === '}' || value === ']')
37
+ stack.pop();
38
+ else if (value === ',' && frame?.object)
39
+ frame.key = true;
40
+ else if (value.startsWith('"') && frame?.object && frame.key) {
41
+ const name = JSON.parse(value);
42
+ if (frame.keys.has(name))
43
+ throw new Error('Duplicate release key');
44
+ frame.keys.add(name);
45
+ frame.key = false;
46
+ }
47
+ position = token.lastIndex;
48
+ }
49
+ return validatePublishedFeed(JSON.parse(text));
50
+ }
51
+ export async function fetchPublishedFeed(fetcher = fetch) {
52
+ const response = await fetcher(RELEASE_FEED_URL, {
53
+ redirect: 'error', signal: AbortSignal.timeout(5000), headers: { Accept: 'application/json' },
54
+ });
55
+ if (response.status !== 200 || !response.body)
56
+ throw new Error('Release feed unavailable');
57
+ const reader = response.body.getReader();
58
+ const chunks = [];
59
+ let size = 0;
60
+ try {
61
+ while (true) {
62
+ const { done, value } = await reader.read();
63
+ if (done)
64
+ break;
65
+ size += value.byteLength;
66
+ if (size > MAX_FEED_BYTES)
67
+ throw new Error('Release feed too large');
68
+ chunks.push(value);
69
+ }
70
+ }
71
+ finally {
72
+ await reader.cancel().catch(() => { });
73
+ }
74
+ return parsePublishedFeed(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
75
+ }
76
+ let cache;
77
+ async function cachedFeed() {
78
+ if (!cache || cache.expires <= Date.now()) {
79
+ // Public facts only: never cache account data, tokens or installed versions.
80
+ cache = { expires: Date.now() + 300_000, value: fetchPublishedFeed() };
81
+ }
82
+ return cache.value;
83
+ }
84
+ const observedSchema = z.object({ version: z.string().nullable(), source: z.enum(['caller_reported', 'runtime', 'unknown']) });
85
+ export const updateInfoOutput = {
86
+ schema_version: z.literal(1), checked_at: z.string(), client: z.string(),
87
+ published: z.object({ plugin: entry, mcp: entry }).nullable(),
88
+ observed: z.object({ plugin: observedSchema, local_mcp: observedSchema, server: observedSchema }),
89
+ update_available: z.object({ plugin: z.boolean().nullable(), local_mcp: z.boolean().nullable(), server: z.boolean().nullable() }),
90
+ compatibility: z.object({ status: z.literal('unknown'), reason: z.string() }),
91
+ distribution_status: z.object({ source_release_published: z.boolean().nullable(), npm_package_published: z.boolean().nullable(),
92
+ remote_deployment_status: z.literal('unknown'), official_registry: z.object({ status: z.literal('unknown'), version: z.null(), checked_at: z.null() }),
93
+ downstream_catalogs: z.record(z.unknown()),
94
+ }),
95
+ instructions: z.object({ reviewed_at: z.string(), source_urls: z.array(z.string()), steps: z.array(z.string()) }).nullable(),
96
+ server_update: z.string(), advisory: z.literal(true), reason: z.string().nullable(),
97
+ };
98
+ function observed(value, runtime = false) {
99
+ return value && value.length < 64 && stable.test(value)
100
+ ? { version: value, source: runtime ? 'runtime' : 'caller_reported' }
101
+ : { version: null, source: 'unknown' };
102
+ }
103
+ function older(current, latest) {
104
+ if (current === null)
105
+ return null;
106
+ const a = current.split('.').map(BigInt), b = latest.split('.').map(BigInt);
107
+ for (let i = 0; i < 3; i++)
108
+ if (a[i] !== b[i])
109
+ return a[i] < b[i];
110
+ return false;
111
+ }
112
+ export async function getUpdateInfo(args, lookup = cachedFeed) {
113
+ let published = null;
114
+ try {
115
+ published = validatePublishedFeed(await lookup());
116
+ }
117
+ catch { /* Do not echo untrusted errors. */ }
118
+ const client = args.client ?? 'generic';
119
+ const observations = { plugin: observed(args.pluginVersion), local_mcp: observed(args.localMcpVersion), server: observed(runtimeVersion, true) };
120
+ return z.object(updateInfoOutput).parse({
121
+ schema_version: 1, checked_at: new Date().toISOString(), client,
122
+ published: published ? { plugin: published.plugin, mcp: published.mcp } : null,
123
+ observed: observations,
124
+ update_available: { plugin: published ? older(observations.plugin.version, published.plugin.version) : null,
125
+ local_mcp: published ? older(observations.local_mcp.version, published.mcp.version) : null,
126
+ server: published ? older(observations.server.version, published.mcp.version) : null },
127
+ compatibility: { status: 'unknown', reason: 'No version-specific client acceptance evidence is supplied by this check.' },
128
+ distribution_status: { source_release_published: published ? true : null, npm_package_published: published ? true : null,
129
+ remote_deployment_status: 'unknown', official_registry: { status: 'unknown', version: null, checked_at: null }, downstream_catalogs: {} },
130
+ instructions: Object.hasOwn(instructions, client) ? instructions[client] : null,
131
+ server_update: 'The hosted gateway is maintained by VPSnet. Updating a local npm package does not update it.',
132
+ advisory: true,
133
+ reason: published ? null : 'Published release information is unavailable or invalid; installed software is unchanged.',
134
+ });
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vpsnet-mcp",
3
- "version": "2.3.14",
3
+ "version": "2.3.16",
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": {
@@ -26,6 +26,7 @@
26
26
  "build/file-restore-contract.js",
27
27
  "build/index.js",
28
28
  "build/service-rescue-contract.js",
29
+ "build/service-readiness.js",
29
30
  "build/temp-vm-contract.js",
30
31
  "build/api-path.js",
31
32
  "build/tool-result.js",
@@ -38,6 +39,9 @@
38
39
  "build/scope-catalog.js",
39
40
  "build/remote-auth-error.js",
40
41
  "build/remote-tools.js",
42
+ "build/read-only-context.js",
43
+ "build/update-info.js",
44
+ "scripts/update-clients.json",
41
45
  "build/remote-orders.js"
42
46
  ],
43
47
  "repository": "github:bacarrdy/vpsnet-mcp",
@@ -0,0 +1,204 @@
1
+ {
2
+ "claude-code": {
3
+ "reviewed_at": "2026-10-14",
4
+ "source_urls": [
5
+ "https://code.claude.com/docs/en/plugins/cli-reference",
6
+ "https://code.claude.com/docs/en/discover-plugins"
7
+ ],
8
+ "steps": [
9
+ "Use claude plugin list --json to check the installed VPSnet plugin and its source.",
10
+ "For the VPSnet marketplace installation, run claude plugin update vpsnet@vpsnet in your own terminal.",
11
+ "Use /reload-plugins in the running session or start a new session. For another install source, use its native update control."
12
+ ]
13
+ },
14
+ "claude": {
15
+ "reviewed_at": "2026-10-14",
16
+ "source_urls": [
17
+ "https://claude.com/docs/plugins/overview"
18
+ ],
19
+ "steps": [
20
+ "Open Customize > Plugins. For an added marketplace, use Check for updates where available.",
21
+ "Uploaded or organization-managed plugins follow their owner's upload, sync and review process. A GitHub release alone does not prove the account's plugin updated."
22
+ ]
23
+ },
24
+ "cowork": {
25
+ "reviewed_at": "2026-10-14",
26
+ "source_urls": [
27
+ "https://claude.com/docs/plugins/overview",
28
+ "https://support.claude.com/en/articles/13837433-manage-plugins-for-your-organization"
29
+ ],
30
+ "steps": [
31
+ "Manage the account plugin in Customize > Plugins using the source's native update controls.",
32
+ "For an organization marketplace, ask its owner to upload or re-sync and complete any required review. The served version can remain pinned or await approval."
33
+ ]
34
+ },
35
+ "grok": {
36
+ "reviewed_at": "2026-10-14",
37
+ "source_urls": [
38
+ "https://docs.x.ai/grok/connectors"
39
+ ],
40
+ "steps": [
41
+ "Manage the custom remote connection at grok.com/connectors.",
42
+ "The official connector guide does not establish plugin ZIP updates or a tool-cache refresh command. A remote gateway deployment does not establish whether this conversation refreshed its tools."
43
+ ]
44
+ },
45
+ "codex": {
46
+ "reviewed_at": "2026-10-14",
47
+ "source_urls": [
48
+ "https://learn.chatgpt.com/docs/cli/reference",
49
+ "https://learn.chatgpt.com/docs/plugins"
50
+ ],
51
+ "steps": [
52
+ "Use codex plugin list --json to inspect installed and available plugin versions and the configured marketplace name.",
53
+ "For that configured marketplace, use codex plugin marketplace upgrade <marketplace-name> --json, then start a new session. Do not assume the active thread reloads plugins."
54
+ ]
55
+ },
56
+ "chatgpt": {
57
+ "reviewed_at": "2026-10-14",
58
+ "source_urls": [
59
+ "https://developers.openai.com/plugins/deploy/connect-chatgpt",
60
+ "https://developers.openai.com/plugins/deploy/submission"
61
+ ],
62
+ "steps": [
63
+ "For a custom MCP connection, open its plugin settings, use Refresh and start a new conversation.",
64
+ "Published package updates and server rescans are publisher actions, with their own review and publication state. A local ZIP or GitHub release does not prove a directory update."
65
+ ]
66
+ },
67
+ "gemini-cli": {
68
+ "reviewed_at": "2026-10-14",
69
+ "source_urls": [
70
+ "https://geminicli.com/docs/extensions/reference/",
71
+ "https://geminicli.com/docs/reference/commands/"
72
+ ],
73
+ "steps": [
74
+ "For a named installed extension, run gemini extensions update vpsnet in your terminal and restart the CLI.",
75
+ "/mcp reload re-discovers tools and /skills reload refreshes skills on host versions that support them; these do not install a new extension."
76
+ ]
77
+ },
78
+ "gemini-browser": {
79
+ "reviewed_at": "2026-10-14",
80
+ "source_urls": [
81
+ "https://support.google.com/gemini/answer/17209137"
82
+ ],
83
+ "steps": [
84
+ "Manage the custom Connected App in Gemini settings. This remote connection is not a Gemini CLI extension.",
85
+ "The reviewed guide does not establish a package-upgrade or tool-cache refresh command."
86
+ ]
87
+ },
88
+ "cursor": {
89
+ "reviewed_at": "2026-10-14",
90
+ "source_urls": [
91
+ "https://cursor.com/docs/reference/plugins",
92
+ "https://cursor.com/docs/plugins"
93
+ ],
94
+ "steps": [
95
+ "For a local plugin copy, replace it with the complete reviewed new package and reload the window or restart.",
96
+ "Public marketplace updates require provider review; team marketplace sync and local updates are separate. No universal public-store update command is established here."
97
+ ]
98
+ },
99
+ "kimi-code": {
100
+ "reviewed_at": "2026-10-14",
101
+ "source_urls": [
102
+ "https://www.kimi.com/code/docs/en/kimi-code-cli/customization/plugins.html"
103
+ ],
104
+ "steps": [
105
+ "For a copied local installation, reinstall the reviewed complete source with /plugins install ./vpsnet, then /reload or start a new session.",
106
+ "Use the plugin manager update action where available. Do not assume custom VPSnet plugins auto-update."
107
+ ]
108
+ },
109
+ "kimi-work": {
110
+ "reviewed_at": "2026-10-14",
111
+ "source_urls": [
112
+ "https://www.kimi.com/en/help/plugins-and-skills/create",
113
+ "https://www.kimi.com/en/help/plugins-and-skills/publish"
114
+ ],
115
+ "steps": [
116
+ "Use Plugin Builder in Kimi Work to convert the reviewed source update and inspect the resulting package.",
117
+ "Personal installation and public marketplace review are separate; no automatic source-update contract was established."
118
+ ]
119
+ },
120
+ "kiro": {
121
+ "reviewed_at": "2026-10-14",
122
+ "source_urls": [
123
+ "https://kiro.dev/docs/powers/installation/",
124
+ "https://kiro.dev/docs/mcp/configuration/"
125
+ ],
126
+ "steps": [
127
+ "For a repository-backed Power, use its Check for updates and Install updates controls.",
128
+ "Standalone MCP configuration is separate. Power OAuth scope compatibility needs client verification; installing a package does not grant additional scopes."
129
+ ]
130
+ },
131
+ "vscode": {
132
+ "reviewed_at": "2026-10-14",
133
+ "source_urls": [
134
+ "https://code.visualstudio.com/docs/agent-customization/agent-plugins",
135
+ "https://code.visualstudio.com/docs/agents/reference/mcp-configuration"
136
+ ],
137
+ "steps": [
138
+ "MCP: Reset Cached Tools refreshes discovery, not installed plugin files.",
139
+ "Use Extensions: Check for Extension Updates for the extension inventory. AgentHost /plugin update applies to its own plugin inventory; inspect which host and install source you use first."
140
+ ]
141
+ },
142
+ "devin": {
143
+ "reviewed_at": "2026-10-14",
144
+ "source_urls": [
145
+ "https://docs.devin.ai/cli/extensibility/plugins/overview",
146
+ "https://docs.devin.ai/cli/extensibility/mcp/configuration"
147
+ ],
148
+ "steps": [
149
+ "For a Devin CLI plugin installation, use devin plugins update vpsnet. A linked local source reloads on a new session.",
150
+ "Legacy Cascade and Devin Local are different hosts; do not assume a Devin CLI action updates a legacy Cascade configuration."
151
+ ]
152
+ },
153
+ "qwen": {
154
+ "reviewed_at": "2026-10-14",
155
+ "source_urls": [
156
+ "https://qwenlm.github.io/qwen-code-docs/en/users/extension/introduction/"
157
+ ],
158
+ "steps": [
159
+ "Use qwen extensions update <installed-name> for a native installed extension, then restart after terminal changes.",
160
+ "Pinned exact npm versions remain pinned. Marketplace source refresh is separate from updating installed extensions."
161
+ ]
162
+ },
163
+ "cline": {
164
+ "reviewed_at": "2026-10-14",
165
+ "source_urls": [
166
+ "https://docs.cline.bot/customization/plugins",
167
+ "https://docs.cline.bot/mcp/mcp-overview"
168
+ ],
169
+ "steps": [
170
+ "Inspect the actual Cline host and installed source; plugin support differs between CLI/SDK and IDE extensions.",
171
+ "No custom-plugin automatic upgrade or remote tool-refresh guarantee was established. Use the host-native manager and current official instructions."
172
+ ]
173
+ },
174
+ "mistral-work": {
175
+ "reviewed_at": "2026-10-14",
176
+ "source_urls": [
177
+ "https://docs.mistral.ai/vibe/work/connectors/mcp-connectors"
178
+ ],
179
+ "steps": [
180
+ "After tools change, open the existing Vibe Work connector and use Functions > Refresh tools. This host does not support dynamic tool discovery.",
181
+ "Do not substitute Vibe Code CLI: direct OAuth MCP is not supported there by the reviewed documentation."
182
+ ]
183
+ },
184
+ "mistral-code": {
185
+ "reviewed_at": "2026-10-14",
186
+ "source_urls": [
187
+ "https://docs.mistral.ai/vibe/code/cli/mcp-servers"
188
+ ],
189
+ "steps": [
190
+ "The reviewed Vibe Code CLI documentation explicitly excludes OAuth MCP servers. A plugin update does not fix that limitation.",
191
+ "Use a supported OAuth client; never substitute customer passwords or manually supplied tokens."
192
+ ]
193
+ },
194
+ "perplexity": {
195
+ "reviewed_at": "2026-10-14",
196
+ "source_urls": [
197
+ "https://www.perplexity.ai/help-center/en/articles/13915507-adding-custom-remote-connectors"
198
+ ],
199
+ "steps": [
200
+ "Manage the existing custom remote connector in account settings. Native skill ZIP replacement is a separate operation.",
201
+ "The reviewed connector guide does not establish automatic tool-list refresh, custom plugin upgrades or release notifications."
202
+ ]
203
+ }
204
+ }