vpsnet-mcp 2.3.2 → 2.3.4

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
@@ -295,7 +295,7 @@ This MCP server manages your VPS infrastructure through the VPSnet.com API, incl
295
295
 
296
296
  1. AI reads the local machine's public key (`~/.ssh/id_rsa.pub`)
297
297
  2. Uploads it to VPSnet.com via `create_ssh_key`
298
- 3. Deploys it to a VPS via `deploy_ssh_key` (or passes it when ordering with `order_service`)
298
+ 3. Deploys it to a VPS via `deploy_ssh_key` with explicit `vm:exec` permission for remote OAuth (or passes it when ordering with `order_service`)
299
299
  4. Connects directly: `ssh root@<vps_ip>`
300
300
 
301
301
  Most AI coding environments (Claude Code, Cline, Cursor, Codex) have built-in terminal access, so the AI can SSH into your VPS immediately after deploying a key — no extra tools needed.
@@ -672,7 +672,7 @@ everything else.
672
672
  | `change_title` | Change service display title | `services:manage` | free |
673
673
  | `toggle_ipv6` | Enable or disable IPv6 | `services:manage` | free |
674
674
  | `toggle_extra_settings` | Toggle ppp, fuse, tuntap, or nfs | `services:manage` | free |
675
- | `deploy_ssh_key` | Deploy an SSH key to a VPS | `services:manage` | free |
675
+ | `deploy_ssh_key` | Deploy an SSH key to a VPS; remote OAuth also requires `vm:exec` | `services:manage` | free |
676
676
 
677
677
  ### OS Reinstall
678
678
  | Tool | Description | Scope | Paid |
@@ -44,8 +44,12 @@ export function createRemoteServer(options) {
44
44
  || typeof value.sub !== "string" || typeof value.grant_id !== "string" || typeof value.scope !== "string"
45
45
  || !Array.isArray(value.services) || !value.services.every(x => typeof x === "string"))
46
46
  return null;
47
+ const scopes = value.scope.split(" ").filter(x => (allowedScopes.includes(x) || x === "vm:files"));
48
+ // orders_available is the issuer's live client-policy decision, not a client-supplied hint.
49
+ // A permitted client still needs actual order authority before order capabilities are exposed.
47
50
  return { accessToken: match[1], subject: value.sub, grantId: value.grant_id,
48
- expiresAt: value.exp, ordersAvailable: options.ordersEnabled === true && value.orders_available === true, scopes: value.scope.split(" ").filter(x => (allowedScopes.includes(x) || x === "vm:files")), services: value.services };
51
+ expiresAt: value.exp, ordersAvailable: options.ordersEnabled === true && value.orders_available === true
52
+ && orderScopes.some(scope => scopes.includes(scope)), scopes, services: value.services };
49
53
  }
50
54
  const http = createServer(async (req, res) => {
51
55
  // TLS terminates at the supplied reverse proxy, which must preserve Host.
package/build/index.js CHANGED
@@ -41,7 +41,7 @@ const EVENT_ID_SCHEMA = z
41
41
  .describe("Numeric `event` id or `noty` UUID returned by the action");
42
42
  export function createVpsnetServer(options = {}) {
43
43
  const server = new McpServer({ name: "vpsnet", version: JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version }, {
44
- instructions: options.remoteScopes ? (options.remoteProfile === "orders" ? orderInstructions : remoteInstructions) : [
44
+ instructions: options.remoteScopes ? (options.remoteProfile === "orders" ? orderInstructions(options.remoteScopes) : remoteInstructions) : [
45
45
  "This MCP server controls VPSnet.com services, including VPS service management, DNS zones, domain registration, domain contacts, API keys, billing, and related paid actions.",
46
46
  "Use the tool descriptions and API-key scopes to choose the correct surface; do not assume this server is limited to VPS-only operations.",
47
47
  "Auth: every request authenticates with the X-API-KEY header (your VPSNet API key). Requests are rate-limited — on HTTP 429, back off and retry after a short delay rather than hammering the endpoint.",
@@ -1661,7 +1661,7 @@ export function createVpsnetServer(options = {}) {
1661
1661
  return { content: [{ type: "text", text: formatJson(data) }] };
1662
1662
  });
1663
1663
  server.registerTool("deploy_ssh_key", {
1664
- description: "Deploy an SSH key to VPS. Returns a noty UUID; follow it with wait_for_event. ASYNC — wait 15-30 seconds after deploying before attempting SSH. Use list_ssh_keys to get available key IDs. To add your own key first: read ~/.ssh/id_rsa.pub from local machine, then create_ssh_key, then deploy it here. Requires services:manage and a full-access API key.",
1664
+ description: "Deploy an SSH key to VPS. Returns a noty UUID; follow it with wait_for_event. ASYNC — wait 15-30 seconds after deploying before attempting SSH. Use list_ssh_keys to get available key IDs. To add your own key first: read ~/.ssh/id_rsa.pub from local machine, then create_ssh_key, then deploy it here. Remote OAuth requires vm:exec; direct API-key use requires services:manage and a full-access key.",
1665
1665
  inputSchema: {
1666
1666
  orderNo: serviceOrderNoSchema,
1667
1667
  ssh_key: positiveIdSchema.describe("SSH key ID from list_ssh_keys"),
@@ -2,7 +2,7 @@ export class RemoteAuthError extends Error {
2
2
  kind;
3
3
  scope;
4
4
  constructor(kind, scope) {
5
- super(kind === 'invalid_token' ? 'The connection has expired or was revoked. Reconnect VPSnet in your AI app.' : 'This action needs an additional permission. Reconnect VPSnet and approve it.');
5
+ super(kind === 'invalid_token' ? 'The connection has expired or was revoked. Re-authorize this existing VPSnet connection in your AI app.' : `This action requires ${scope || 'an additional permission'}. Ask the customer to approve this permission by re-authorizing this existing VPSnet connection in their AI app.`);
6
6
  this.kind = kind;
7
7
  this.scope = scope;
8
8
  }
@@ -7,7 +7,7 @@ export const remoteInstructions = [
7
7
  "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.",
8
8
  "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.",
9
9
  "Use the connected VPSnet account and only the services and permissions it granted. Never request a model API key.",
10
- "Tool discovery describes supported capabilities, not granted authority. If a needed permission is missing, reconnect through OAuth and let the customer select it; never work around a denial.",
10
+ "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.",
11
11
  "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.",
12
12
  "Keep operation IDs, reason codes and idempotency keys. Accepted means pending; read the final state before claiming success.",
13
13
  "On disconnect or uncertain response, inspect the existing operation. Never issue a fresh mutation or new idempotency key automatically.",
@@ -19,10 +19,19 @@ export const remoteInstructions = [
19
19
  "This connector does not purchase capacity, change payment settings or run model inference. Functions execution uses the customer's VPSnet balance; an AI subscription does not pay those infrastructure charges.",
20
20
  ].join("\n");
21
21
  export const orderScopes = ["orders:read", "orders:servers", "orders:domains"];
22
- export const orderInstructions = remoteInstructions.replace("This connector does not purchase capacity, change payment settings or run model inference.", "This VPSnet connection can purchase permanent VPS servers and register domains from the customer’s VPSnet balance when purchase permissions are enabled. Use the existing connection; its owner can change purchase permissions in the VPSnet panel without reconnecting. Quote first, show the final gross EUR amount and period, and confirm only after explicit approval. 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.");
22
+ export function orderInstructions(scopes) {
23
+ if (!orderScopes.some(scope => scopes.includes(scope)))
24
+ return remoteInstructions;
25
+ const products = [scopes.includes('orders:servers') ? 'purchase permanent VPS servers' : '',
26
+ scopes.includes('orders:domains') ? 'register domains' : ''].filter(Boolean);
27
+ const instructions = products.length
28
+ ? `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.`
29
+ : 'This VPSnet connection can inspect order plans and existing purchase results within its granted permissions. It cannot submit purchases or change payment settings.';
30
+ return remoteInstructions.replace('This connector does not purchase capacity, change payment settings or run model inference.', instructions);
31
+ }
23
32
  export const supportedScopes = ["services:read", "services:manage", "applications:read", "applications:manage", "networking:read", "networking:manage", "functions:read", "functions:manage", "functions:invoke", "vm:diagnostics", "vm:files:read", "vm:files:write", "vm:exec", "services:reinstall", "services:restore", "services:rescue", "dns:read", "dns:write", "dnssec:manage", "domains:read", "domains:manage", "certificates:read", "certificates:manage", "ssh_keys:read", "ssh_keys:manage"];
24
33
  const readServices = ["get_service_rescue", "get_guest_agent_status", "get_plan_options", "get_plan_resources", "calculate_plan_change", "get_period_options", "list_restore_points", "list_restore_file_points", "browse_restore_files", "get_restore_file_browse", "list_services", "get_service", "get_service_graphs", "get_service_history", "get_hostname", "get_title", "get_rdns", "get_os_options", "get_backup_status", "get_backup_history", "list_snapshots", "list_firecracker_snapshots", "get_firecracker_backup_status", "get_firecracker_backup_history"];
25
- const manageServices = ["suspend_service", "resume_service", "flush_iptables", "toggle_ipv6", "toggle_extra_settings", "deploy_ssh_key", "reinstall_os", "rollback_snapshot", "delete_snapshot", "rollback_firecracker_snapshot", "delete_firecracker_snapshot", "start_service", "stop_service", "restart_service", "change_hostname", "reset_hostname", "change_title", "change_rdns", "clear_rdns"];
34
+ const manageServices = ["suspend_service", "resume_service", "flush_iptables", "toggle_ipv6", "toggle_extra_settings", "reinstall_os", "rollback_snapshot", "delete_snapshot", "rollback_firecracker_snapshot", "delete_firecracker_snapshot", "start_service", "stop_service", "restart_service", "change_hostname", "reset_hostname", "change_title", "change_rdns", "clear_rdns"];
26
35
  const readApps = ["list_application_registry_credentials", "get_application_data_restore", "list_application_catalog", "list_service_applications", "get_application_installation", "get_application_events", "get_application_health", "get_application_logs", "list_application_recipes", "list_application_recipe_revisions", "export_application_recipe", "discover_service_containers", "get_application_compose_adoption"];
27
36
  const manageApps = ["configure_application_resource_thresholds", "validate_application_recipe", "create_application_recipe", "create_application_recipe_revision", "install_application_recipe", "prepare_application_compose_adoption", "confirm_application_compose_adoption", "install_application", "configure_application_access", "manage_application", "cancel_application_action"];
28
37
  const permissions = new Map([
@@ -97,6 +106,7 @@ const permissions = new Map([
97
106
  ["get_ssh_key", "ssh_keys:read"],
98
107
  ["create_ssh_key", "ssh_keys:manage"],
99
108
  ["delete_ssh_key", "ssh_keys:manage"],
109
+ ["deploy_ssh_key", "vm:exec"],
100
110
  ["enter_service_rescue", "services:rescue"],
101
111
  ["exit_service_rescue", "services:rescue"],
102
112
  ["get_system_status", "authenticated"],
@@ -153,13 +163,16 @@ const deduplicatedTools = new Set([
153
163
  'run_vm_command', 'inspect_vm', 'read_vm_file', 'write_vm_file', 'create_vm_file', 'cancel_vm_operation',
154
164
  'create_function', 'invoke_function', 'confirm_service_order', 'confirm_domain_register',
155
165
  ]);
156
- /** Keep supported tools discoverable for incremental OAuth consent; purchases stay outside this profile. */
166
+ /** Keep management tools discoverable for incremental consent; order tools require granted authority. */
157
167
  export function installRemotePolicy(server, scopes, profile = 'directory') {
158
168
  const original = server.registerTool.bind(server);
159
169
  const register = (name, config, handler) => {
160
170
  const required = remoteScope(name, profile);
161
171
  if (!required)
162
172
  return undefined;
173
+ const orderTool = profile === 'orders' && orderPermissions.has(name);
174
+ if (orderTool && (!orderScopes.some(scope => scopes.includes(scope)) || !hasScope(scopes, required)))
175
+ return undefined;
163
176
  const requiredScopes = required === "authenticated" ? [] : required === "vm:session"
164
177
  ? (scopes.some(s => s.startsWith("vm:")) ? scopes.filter(s => s.startsWith("vm:")) : ["vm:diagnostics"]) : [required];
165
178
  const readOnly = name === 'validate_application_recipe' || required === "authenticated" || required.endsWith(":read") || required === "vm:diagnostics";
@@ -210,11 +223,13 @@ export function installRemotePolicy(server, scopes, profile = 'directory') {
210
223
  _meta: { ...(config._meta || {}), securitySchemes },
211
224
  }, async (...args) => {
212
225
  const context = authorizationContext();
226
+ if (orderTool && (!context?.ordersAvailable || !hasScope(context.scopes, required))) {
227
+ return { isError: true, content: [{ type: 'text', text: 'This action is not available for this connection.' }] };
228
+ }
213
229
  if (!context || !hasScope(context.scopes, required)) {
214
- if (required.startsWith('orders:'))
215
- return { isError: true, content: [{ type: 'text', text: 'Ordering permission is disabled. The customer can enable purchases and spending limits for this existing connection in VPSnet → AI connections → Purchase permissions. Do not add another connection. After permission is enabled, show the exact quote and ask for approval of the balance charge.' }] };
216
- return { isError: true, content: [{ type: "text", text: "Reconnect VPSnet with the required permissions." }],
217
- _meta: { "mcp/www_authenticate": [`Bearer error="insufficient_scope", error_description="Reconnect VPSnet to allow this action on your selected services.", scope="${requiredScopes.join(" ")}"`] } };
230
+ const message = `This action requires ${requiredScopes.join(' ')}. Ask the customer to approve this permission by re-authorizing this existing VPSnet connection in their AI app.`;
231
+ return { isError: true, content: [{ type: "text", text: message }],
232
+ _meta: { "mcp/www_authenticate": [`Bearer error="insufficient_scope", error_description="${message}", scope="${requiredScopes.join(" ")}"`] } };
218
233
  }
219
234
  let result;
220
235
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vpsnet-mcp",
3
- "version": "2.3.2",
3
+ "version": "2.3.4",
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": {