Servers — API v1 and MCP | Nodea Documentation
The Servers module is the largest part of the infrastructure API: the full lifecycle of a managed server — create, edit, delete, SSH connection test, note, clone, performance statistics, components (software installation), Ansible operation history, WAF status with rules, and the lifecycle of a paired Proxmox virtual machine (start / reboot / stop). This document covers the REST API v1 surface (/api/v1/servers) and the 17 MCP tools in this module.
Authentication and scopes
API v1 authenticates with a Passport personal access token via the Authorization: Bearer <token> header. Access tiers:
infra:read— list, show, statistics, components, operations, WAF status.infra:write— create, edit + non-destructive actions (SSH test, note, clone, component install, VM start and reboot, WAF rule toggle).infra:delete— delete a server (cascades to destroy the paired VM) and VM stop (powering off a production machine is disruptive).
The whole module is additionally gated by the Pennant flag features:servers — disabling the module hides it on both web UI and API/MCP. Rate limit: throttle:api-v1 = 240/min per token user.
Ownership and visibility (ServerPolicy)
Row-level access is enforced by ServerPolicy: view = owner OR accepted FOREIGN share (a collaborator can consume every read endpoint), manage = owner only (the OWNER pivot). All mutations require manage. A server that exists but isn't visible to the token yields 403 (policy); an unknown id yields 404 (route model binding). Listing uses User::servers(), so not-yet-accepted invitations never leak while accepted FOREIGN shares are included (parity with web).
Security — what does NOT enter the response
Field selection is explicit and deliberately omits everything sensitive:
- ServerResource — omits the
sshKeyrelation (encrypted public/private deploy-key pair, loaded on every query viaServer::$with) and the rawinfoblob from the bootstrap script. - VirtualMachineResource — never serializes the VM
root_password(encrypted, admin reveal only), the PVE cluster coordinates or the per-VM Proxmox user. - ServerComponentResource — masks secret-looking keys in
data_rows(containingpass,secretortoken→********); manage actions persistdb_passand friends into the encrypteddatablob, which never round-trips through the API. - ServerOperationResource — does not serialize the raw
outputblob (full Ansible JSON with possible playbook variables); the machine-readablestatusis the API contract.
API v1 — endpoints
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/servers | infra:read | The user's servers (owned + accepted shares), paginated; ownership. |
| GET | /api/v1/servers/{server} | infra:read | Server detail (components + paired VM). |
| GET | /api/v1/servers/{server}/statistics | infra:read | CPU/RAM/disk series (latest Prometheus samples). |
| GET | /api/v1/servers/{server}/components | infra:read | Server components and their status. |
| GET | /api/v1/servers/{server}/components/{component} | infra:read | Component detail (scoped binding) with data_rows. |
| GET | /api/v1/servers/{server}/operations | infra:read | Operation history (Ansible), newest first; simplePaginate. |
| GET | /api/v1/servers/{server}/waf | infra:read | WAF status: has_agent, rules, latest 20 events. |
| POST | /api/v1/servers | infra:write | Create a server (creator = OWNER). Returns 201. |
| PUT/PATCH | /api/v1/servers/{server} | infra:write | Edit name/SSH port (IP immutable; owner-only). |
| POST | /api/v1/servers/{server}/test-ssh | infra:write | SSH connection test with the deploy key. {ok, message} or 502. |
| PATCH | /api/v1/servers/{server}/note | infra:write | Set/clear the note. |
| POST | /api/v1/servers/{server}/clone | infra:write | Clone a server (create-server quota; VM detached). Returns 201. |
| POST | /api/v1/servers/{server}/components/{component}/install | infra:write | Install a component (ansible queue). Returns 202 + the operation. |
| POST | /api/v1/servers/{server}/vm/start | infra:write | Start the VM (async). 422 when no VM paired. |
| POST | /api/v1/servers/{server}/vm/reboot | infra:write | Reboot the VM (synchronous PVE). 422/409/502. |
| POST | /api/v1/servers/{server}/waf/rules/{rule}/toggle | infra:write | Enable/disable a WAF rule; enabled (bool). 422 without a fleet agent. |
| DELETE | /api/v1/servers/{server} | infra:delete | Delete the server + async destroy the VM. Returns 204. |
| POST | /api/v1/servers/{server}/vm/stop | infra:delete | Stop the VM (destructive — powers off a production machine). 422 without a VM. |
Total: 18 endpoints (7 read, 9 write, 2 delete).
GET /servers — query parameters
search(string, optional, max 255) — matched against name and IP.sort(enum:name|ip|created_at, defaultcreated_at) — hard whitelist.direction(asc|desc; defaults todescforcreated_at).per_page(int 1–100, default 20),page(int ≥1).
ServerResource — fields
id (UUID), name, ip, ssh_port, status (lowercase: new|connected|installed|error), note, ownership (owner|shared, index only via the pivot), has_virtual_machine (bool), virtual_machine (object or null), components[] (when loaded), created_at, updated_at. The virtual_machine object: id, vm_id (Proxmox VMID), name, ip, status, last_error, plus optional plan, os_template, location.
POST/PATCH /servers — body parameters
name(string, required, min 3, max 100).ip(required for POST,ipv4; immutable — absent from PATCH).ssh_port(required, numeric).
Note: note (string, nullable). WAF toggle: enabled (bool, required). Install: the URL param {component} = a Component enum value (e.g. nginx, php83, mariadb1011, redis, postgres16, nodejs20).
Error codes
401— missing/invalid token.403— missing scope or an existing, not-visible server (policy).404— unknown id; a component from another server (scoped binding).409— no PVE assignment on reboot (no_pve_assignment).422— validation; no paired VM (no_virtual_machine); no fleet agent (no_fleet_agent).502— failed SSH test / failed reboot (reboot_failed).
Examples — API
List servers, sorted
curl -s "https://app.nodea.io/api/v1/servers?sort=name&direction=asc&per_page=25" \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Accept: application/json"
Create a server
curl -s -X POST https://app.nodea.io/api/v1/servers \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "web-01", "ip": "203.0.113.10", "ssh_port": 22 }'
Install a component
curl -s -X POST https://app.nodea.io/api/v1/servers/{id}/components/nginx/install \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Accept: application/json"
Reboot the virtual machine
curl -s -X POST https://app.nodea.io/api/v1/servers/{id}/vm/reboot \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Accept: application/json"
Stop the VM (destructive, infra:delete scope)
curl -s -X POST https://app.nodea.io/api/v1/servers/{id}/vm/stop \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Accept: application/json"
MCP — tools (17)
The MCP tools share the authentication and scope model with API v1 (Bearer + scope in ensureScope) and the features:servers flag. Servers are addressed by UUID; a missing or inaccessible id raises the same error (no cross-tenant enumeration — InteractsWithServers::serverFor defers to the view/manage policies). Secrets are masked exactly as in the API (the same resource shapes). Destructive tools (server-delete, server-vm-stop) require confirm: true.
| Tool | Scope | Mode | Description |
|---|---|---|---|
| servers-list | infra:read | read-only | List servers (owned + FOREIGN shares); search, page, per_page (max 50). Returns the ownership label. |
| server-get | infra:read | read-only | Server detail (components + VM). Param: id. |
| server-statistics | infra:read | read-only | CPU/RAM/disk series. Param: id. |
| server-components | infra:read | read-only | Component list; id, page, per_page. |
| server-operations | infra:read | read-only | Operation history; id, component filter, pagination. No raw playbook output. |
| server-waf-status | infra:read | read-only | WAF status: has_agent, rules, latest 20 events. Param: id. |
| server-create | infra:write | write | Create a server; name (min 3), ip (ipv4), ssh_port. create-server quota. |
| server-update | infra:write | idempotent | Edit name/ssh_port (partial; IP immutable). Owner-only. |
| server-set-note | infra:write | idempotent | Set/clear the note; id, note (nullable). |
| server-test-ssh | infra:write | write (dials infra) | SSH test with the deploy key; {connected, error}. Owner-only. |
| server-clone | infra:write | write | Clone a server; id. create-server quota; VM detached. |
| server-component-install | infra:write | write | Install components; id, components[] (min 1, enum values). Ansible queue, staggered 10s apart. |
| server-vm-start | infra:write | write | Start the VM; id. no_virtual_machine when no VM. |
| server-vm-reboot | infra:write | write | Reboot the VM (synchronous PVE); id. |
| server-waf-rule-toggle | infra:write | idempotent | Toggle a WAF rule; id, rule_id (max 64), enabled. Ships the full override set to the nodead agent. |
| server-delete | infra:delete | destructive | Delete the server + async destroy the VM; id + required confirm: true. Owner-only. |
| server-vm-stop | infra:delete | destructive | Stop the VM (offline until restarted); id + required confirm: true. Owner-only. |
Breakdown: 6 read-only, 9 write, 2 destructive.
Examples — MCP (JSON-RPC)
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "server-component-install",
"arguments": { "id": "9b1f...uuid", "components": ["nginx", "php83"] }
}
}
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "server-vm-stop",
"arguments": { "id": "9b1f...uuid", "confirm": true }
}
}