Skip to content

Latest commit

 

History

History
867 lines (659 loc) · 88.3 KB

MCP_API_REFERENCE.md

File metadata and controls

867 lines (659 loc) · 88.3 KB

MCP API Reference

Status: active

Action catalog for the system extension's MCP surface. Lists every system_*, system_sdwan_*, kubernetes_*, and docker_* action callable via the Powernode MCP server.

Audience: AI Concierge prompt authors, external operators integrating with the platform's MCP server, contributors adding new actions.

Where actions are registered (architecture note)

Architecture: The MCP registry (action-name → tool-class mapping) lives in the parent platform at server/app/services/ai/tools/platform_api_tool_registry.rb. The tool class implementations live in the extension at extensions/system/server/app/services/ai/tools/. Rails autoloading resolves the class names across both locations.

flowchart LR
    subgraph Parent["Parent platform"]
        Reg["platform_api_tool_registry.rb"]
        M1["system_* → Ai::Tools::SystemFleetTool"]
        M2["system_sdwan_* → Ai::Tools::SdwanTool"]
        M3["kubernetes_* → Ai::Tools::Kubernetes*Tool"]
        M4["docker_* → Ai::Tools::Docker*Tool"]
        Reg --- M1
        Reg --- M2
        Reg --- M3
        Reg --- M4
    end

    subgraph Ext["System extension"]
        T1[system_fleet_tool.rb]
        T2[sdwan_tool.rb]
        T3[kubernetes_*_tool.rb]
        T4[docker_*_tool.rb]
        Svc[/services/system/<br/>*_service.rb<br/>lifecycle + biz logic/]
        T1 --> Svc
        T2 --> Svc
        T3 --> Svc
        T4 --> Svc
    end

    MCP[(MCP client<br/>call to action)] --> Reg
    M1 -. "class-name string<br/>resolved by autoloader" .-> T1
    M2 -.-> T2
    M3 -.-> T3
    M4 -.-> T4
Loading

To add a new MCP action: register the action-name → class-name mapping in the parent's registry file, then implement the action method in the corresponding tool class inside the extension. Both repos commit; bump the submodule pointer.

To regenerate the parent's full tool catalog with parameter schemas:

cd server && bundle exec rails mcp:generate_tool_catalog
# → writes to docs/platform/MCP_TOOL_CATALOG.md (gitignored)

This document is the system-extension subset of that catalog — manually curated, scannable for operator workflows.

Permission model

Every action requires a permission grant on the calling user/agent. Permissions follow the schema <resource>.<verb> (e.g., system.nodes.read, system.modules.write). The mapping lives in server/db/migrate/*_permissions.rb (parent platform). Agents have permissions assigned via Ai::AgentPermission.

Action catalog

system_* — Fleet, lifecycle, modules, storage, architecture, GitOps, CI workers, disk image CI, providers, topology, ingress, GPU/inference, agent-fleet, A2A (134 actions)

Backed by Ai::Tools::SystemFleetTool (parent-registered, extension-implemented) plus the managed-Docker, package-repository, architecture-catalog, disk-image, ingress, storage-owner, and agent-fleet tool classes. The registry routing lives in the parent (platform_api_tool_registry.rb); the tool-class implementations live in the extension under server/app/services/ai/tools/.

Nodes + instances

Action What it does Audience
system_list_nodes List Node rows, one page at a time (limit / cursor). It declares exactly two FILTERS, template_id and environment — there is no status and no lifecycle_class filter, and an undeclared key is dropped silently, so filtering by one returns the unfiltered list with no error. environment fails closed: a plane this account does not have is an error, never a silent whole-fleet answer operator, agent
system_get_node Fetch a Node by id operator, agent
system_create_node Create a Node row (no provider VM yet) operator, agent
system_list_instances List NodeInstance rows operator, agent
system_get_instance Fetch NodeInstance details (status, last_heartbeat, running modules) operator, agent
system_provision_instance Trigger provider VM creation; creates Task; returns NodeInstance operator, agent
system_terminate_instance Destroy provider VM; cascade-FK cleanup operator, agent
system_replace_instance DR: move an unrecoverable instance's volumes, SDWAN membership and VIPs onto a warm pool member. Approval-gated (system.instance_replace); terminates nothing — pass reap: true for a SECOND, separately-gated terminate. Refused for a target still running and still reporting unless accept_running: true; denied outright to MCP instance principals by the deny overlay (*replace_instance*), so reap: true cannot be smuggled through it operator
system_reap_instance DR: terminate the unrecoverable instance whose workload system_replace_instance has already moved. Approval-gated (system.instance_reap); denied outright to MCP instance principals by the deny overlay operator
system_drift_report Compare running module digests vs assigned modules. Returns missing / extra / mismatched (each with a *_count; any sets drift: true) plus unverifiable / unverifiable_count — assigned modules whose served version has no oci_digest, so the running digest cannot be compared; they do not set drift and are never extra operator, agent

Templates + modules

Action What it does
system_list_templates List NodeTemplates
system_get_template Fetch a Template + its module assignments
system_update_template Edit any field system_create_template accepts: name, description, enabled, public, node_platform_id, admin_user, config (config replaces the stored hash)
system_assign_module_to_template Add a module to a Template, with the join's own priority, enabled, config and recommends_override. (config is stored verbatim and is delivered by whatever consumes it; target_cluster_id on a k3s-agent assignment is delivered to the agent as of IMP-a5f236e8cc56, see CONTAINER_RUNTIMES.md.) Refuses error-severity composition conflicts; enabled: false stages the assignment without shipping it and skips the conflict check
system_update_template_module Edit an existing assignment in place (priority/enabled/config/recommends_override). enabled: false is the reversible way to remove a module — the join survives and its derived NodeModuleAssignments stay until a purging apply; unassigning destroys the join and purges them at once. Re-enabling runs the conflict check
system_list_node_module_assignments List a Node's NodeModuleAssignments (paginated, limit / cursor): module id + name, enabled, priority, source_template_module_id (null = hand-authored) and timestamps. Only enabled rows are served to the node's agent. Requires system.modules.read
system_assign_module_to_node Create a NODE-level assignment — how a node gains a module its template does not name. Refuses a module disabled in the catalog. Refuses a module with a hard (required, transitive) dependency the node neither has assigned-and-enabled nor gets from its template closure, or that is catalog-disabled, naming the missing modules — nothing is auto-assigned; assign them first. Refuses a module already assigned to the node (enabled or not — use system_update_module_assignment), and refuses error-severity composition conflicts against the node's enabled assignments, whatever enabled is; the dependency check likewise (the toggle runs neither, so create-disabled-then-enable would otherwise bypass them). Only DECLARED conflicts are seen. The row is hand-authored (source_template_module_id null), so template reconciliation never reaps it. Nothing is pushed: the agent picks it up on its next module sync. Requires system.modules.update
system_list_modules List NodeModules
system_get_module Fetch a Module + its categories + dependencies
system_list_module_versions List versions of a module
system_promote_module_version Promote a version INTO an environment: writes that pinned plane's pin, one rung at a time, gated in the target plane. Moves that plane's pin only — it does not change which version the FOLLOWING planes serve (NodeModule#current_version_id), which a publish moves. Use system_rollback_module_version with environment: to walk one pin back, or without it to repoint the fleet
system_validate_module_manifest Dry-run validation of a manifest.yaml payload against the manifest schema before publishing

Tasks

Action What it does
system_list_tasks List Tasks (filter by node_id, instance_id) — newest first, one page at a time (limit / cursor, with count the uncapped total); there is no status or type filter
system_get_task Fetch one Task (account-scoped): command, status, progress, operable handle, timestamps and error_message (full stored failure reason, 16 KB cap, redacted of credential-shaped tokens). include_events: true adds its events — see "Task events" below; wait_seconds long-polls, see "Waiting instead of polling"
system_get_task_log One bounded page of a Task's FULL stored log (a module build's whole scrubbed output, not just the log_tail on its events) — see "Full task log" below. SELF-ONLY for an instance principal
system_cancel_task Cancel a pending or in-flight Task
Task events (include_events)

System::Task has no result column, so a handler's result rides the task's events (events[].result). A module build's scrubbed log_tail is there on success as well as failure, and until include_events nothing on the tool surface served it. system_get_task with include_events: true adds three keys to the reply: events (the newest 20, oldest first, each with its type, message, timestamp, data and any result), events_total and events_truncated (true when older events were dropped). Absent or false the reply is unchanged; a value that is not true or false (or those two strings) is refused, naming the parameter. It composes with wait_seconds: the events are those of the final snapshot. Account scoping is the same as the plain read, and it needs no permission beyond system.infra_tasks.read.

Every string in the events, hash keys and log_tail included, goes through the same System::ShellOutputSanitizer.redact_text pass as error_message (mechanism and size policy in System::StoredOutputRedactor), so a credential-shaped token planted anywhere in an event comes back as [REDACTED]. Redaction always runs before any cut and the emitted slice is checked once more, so no fragment of a secret survives truncation. The sanitizer's patterns key on what precedes a secret, so structure is handled explicitly and fails closed:

  • A string under a hash key is redacted together with the key. If redaction rewrote the key itself, the value is withheld whole.
  • The strings of an array (nested arrays flattened, numbers and booleans stringified) are checked as one string, joined by a space and again by a newline: an argv, a PEM split into lines or a .netrc line split into words. If anything in it is redacted, every string of the array, nested ones included, is withheld, because a redaction cannot be reliably mapped back onto elements. A log-line array containing one credential therefore loses all its lines. An array too large to check (over 500 strings or 256 KB) is withheld the same way. A tail-bounded array (under a log, stderr... key) whose retained items start with PEM body lines drops them, since their BEGIN line was cut away.
  • A key that names a secret withholds its whole subtree (a string, a number, a nested object or an array alike). Matching is case-insensitive over the snake-cased key: as a substring for password, passwd, passphrase, secret, token, api_key, access_key, private_key, credential, authorization and the rest of Ai::SensitiveParams.key_patterns (which includes any deployment-configured additions), and as a whole segment for pass, pwd, pin, otp, totp, passcode, mfa, auth, cookie, session and signature, so bypass and author are left alone. Any key ending in key or keys (master_key, ssh_keys, a bare key) is withheld too, except sort_key, cache_key, primary_key, foreign_key, idempotency_key, partition_key and public_key. A number under a *_count, *_ttl, *_seconds and similar key (token_count) is kept. When in doubt it redacts.

The payload is bounded on every axis:

  • Strings: 16 KB each, the truncation marker included. A log_tail (and any key named log, tail, stdout, stderr, output or trace) keeps its end and marks the cut at the front ([truncated]...); every other string keeps its start (...[truncated]).
  • Budget: 128 KB in all, charged for strings, keys, numbers and markers alike and spent newest-first. Once it is gone the rest of a container collapses into one [omitted ...] entry, and the older events into one leading [omitted: N older events ...] element.
  • Structure: at most 5000 values per reply, 6 levels of nesting, 50 keys or items per container, and keys are capped at 200 characters. Two keys that come out the same after redaction or truncation stay apart as <key> and <key>#2.

error_message itself is unchanged: head-bounded, marker added after the limit. Redaction is best-effort, the sanitizer's usual coverage: a secret shape with no pattern, under a key with no secret name, is not caught.

Full task log (system_get_task_log)

A module build's log_tail keeps only the last 4 KB of stdout and 128 KB of stderr, so a failure whose cause scrolled out of that window could not be diagnosed over MCP. The agent now also uploads the WHOLE scrubbed build log (POST node_api/status/tasks/:id/log, on the failure path as well as success), scrubbed on the node with the same secret scrub and private-key removal as log_tail, capped at 1 MiB with the END kept; truncated and original_bytes say when and by how much it was cut. The upload is best-effort and never fails a build. Only a task the instance itself owns, and only once it is running or finished, accepts a log.

The platform stores it in system_task_logs (one row per task, replaced by a re-upload), redacts it through System::StoredOutputRedactor when it is written AND again over the whole text on every read (so a secret split across a page boundary is redacted whole, and a pattern learned later covers older rows), and keeps it for 14 days by default (system.task_log.retention_days); expired rows are neither served nor kept, and an instance's expired rows are pruned on each of its uploads.

system_get_task_log returns one page: content, offset, next_offset, has_more, total_bytes, truncated, original_bytes, expires_at. Pass next_offset back as offset until has_more is false; offsets are byte offsets on a character boundary, so the pages concatenate to the whole log. limit defaults to 64 KiB and is clamped at 256 KiB. A task with no stored log (never uploaded, or expired) is an error saying so; a task that does not exist, is in another account, or (for an instance principal) belongs to another instance is the same not-found error. Only ci.module_build uploads a log today: ci.package_build and ci.lint_discovery still carry only their log_tail.

Instance pools (slice 7)

Action What it does
system_list_instance_pools List InstancePools
system_get_instance_pool Fetch a pool + its members
system_create_instance_pool Create a new pool (target_size, min, max, region, type, template). Approval-gated (system.instance_pool_create) — returns pending: true + a deferred_operation_id and no pool exists until approved
system_update_instance_pool Tune a pool (sizes, status, region/type, metadata). Partially approval-gated: a target_size/max_size INCREASE on system.instance_pool_ceiling_raise and status: "archived" on system.instance_pool_archive return pending: true + a deferred_operation_id and write nothing until approved; decreases, min_size, description, regions, metadata and status active/paused/draining apply inline
system_drain_instance_pool Stop replenishing (enforced — replenish! refuses a non-active pool) + terminate ready members; claimed members keep running. Reversible: status: "active" warms the pool back to its standing target_size
system_delete_instance_pool Remove a drained pool (refuses while members are still attached)
system_acquire_pooled_instance Atomic claim of a ready member
system_return_pooled_instance Return a claimed member to the pool (reaper resets state)
system_replenish_instance_pool Manual trigger of the reaper for this pool

Container runtimes

Action What it does
system_provision_docker_runtime Phase 1 Docker daemon provisioning on a NodeInstance
system_decommission_docker_runtime Destroy managed DockerHost row + Vault TLS material
system_mark_docker_ready Agent-side ack endpoint (mostly internal)
system_list_managed_docker_hosts List Powernode-managed Docker hosts (excludes externally-registered)

Package repositories + catalog

Backed by Ai::Tools::SystemPackageRepositoryTool. Manages apt/rpm package repositories, browses the synced catalog with rich filtering, and materializes packages into NodeModules with full dependency closure.

Action What it does Audience
system_list_package_repositories List accessible apt/rpm repositories (account-scoped + shared); filter by kind and node_platform_ids[]. Each row carries embedding_pending_count for catalog coverage. operator, agent
system_get_package_repository Fetch one repository with detail (apt_config, rpm_config, linked NodePlatforms) operator, agent
system_create_package_repository Register a new apt/rpm/dnf repository (visibility: shared requires system.package_repositories.manage_shared) operator
system_update_package_repository Update repository config (architectures, enabled, priority, apt/rpm config) operator
system_delete_package_repository Delete a repository (only when no modules link to its packages) operator
system_sync_package_repository Trigger an immediate upstream sync (fetches index, upserts Package rows). Enqueues SystemPackageEmbeddingJob after successful sync. operator, agent
system_link_repository_platform / system_unlink_repository_platform M:N link/unlink between repository and NodePlatform operator
system_search_packages Search the synced catalog. Hybrid trigram+embedding ranking (default), pure lexical, or pure semantic. Filters: q, mode (lexical/semantic/hybrid), repository_ids[], kind, architectures[] (canonical, cross-kind expanded), sections[], license, provides (capability lookup), sort, page, per_page. Back-compat with singular repository_id/architecture/section. Response includes similarity (when mode ≠ lexical), provides_names, license, applied_filters echo. total is null under semantic/hybrid (exact COUNT prohibitive on vector-filtered sets). operator, agent
system_discover_packages Intent-based semantic discovery — describe a capability ("reverse proxy", "distributed cache") and get ranked packages. Pure cosine-distance ranking via pgvector. Inputs: intent (required), repository_ids[], kind, architectures[], license, top_k (1-50, default 10). Returns {results: [{package_id, name, version, architecture, summary, similarity, repository_id, reason}], seed_count, confidence (high/medium/low)}. Use system_search_packages instead when you already know the package name. operator, agent
system_get_package Fetch one Package row with full metadata (depends, recommends, provides, conflicts, license, maintainer) operator, agent
system_resolve_package_dependencies Preview the dependency closure of a package without writes — required + recommends candidates the operator can opt into operator, agent
system_create_module_from_package Materialize a package + transitive deps as NodeModule rows + ModuleDependency edges; dispatches CI build per architecture operator, agent
system_list_package_module_links Auditable provenance — which NodeModules came from which packages, top-level vs auto-generated operator, agent
system_refresh_package_module Re-materialize a NodeModule when upstream drifts (replays persisted recommends_chosen for deterministic refreshes) operator, agent
system_suggest_architectures_for_fleet T2.B — fleet-aware architecture suggestion for materialization; intersects repo's archs with NodePlatform coverage operator, agent

Embedding pipeline: Package.embedding (pgvector 1536-dim) is populated by SystemPackageEmbeddingJob (worker-side). The job is auto-enqueued after every sync that upserts ≥1 row, and can be manually run via rake system:packages:backfill_embeddings. Without embeddings, hybrid mode contributes only the trigram leg — search still works, just without semantic ranking.

Permission map:

  • system.package_repositories.{view,create,update,delete,sync,manage_shared} — repository CRUD + sync
  • system.packages.{view,search} — catalog read access (search covers both search and discover)
  • system.package_modules.{view,create,refresh} — materialization + provenance
  • system.packages.embed (worker-only) — embedding writeback

Architecture catalog

Action What it does Audience
system_list_architectures List System::Architecture rows in the account catalog operator, agent
system_get_architecture Fetch one row with its package list + capability tags operator, agent
system_create_architecture Create a new architecture (named composition of packages + capabilities) — autonomy-gated via system.architecture.create policy operator, agent
system_update_architecture Edit packages / capability tags on an existing architecture operator, agent
system_delete_architecture Remove an architecture; refuses if any Node references it operator, agent
system_propose_architecture Operator-facing review surface — propose a new architecture for human approval before catalog insertion operator, agent

Permissions: system.architectures.{view,create,update,delete,propose}

GitOps reconciliation

Action What it does Audience
system_gitops_list_repositories List the account's registered repositories — over MCP, the only way to discover their ids operator, agent
system_gitops_get_repository Read one repository's configuration + credential contract (vault_credential_path, required_credential_keys — path and key NAMES, never values) operator, agent
system_gitops_register_repository Register a Git repository as a fleet-state source (URL + branch + auth ref). Approval-gated under system.gitops_register_repository (Ai::AutonomyGate, replayed by Ai::Executors::DeferredToolCall): when policy requires approval it answers pending: true and no repository exists until an operator approves. Parameters are validated before the request is queued, so a rejected repo_url still fails inline operator, agent
system_gitops_sync_repository Trigger a sync (fetch + parse fleet.yaml + compute diff against effective state). A FAILED reconcile answers success: false with the reason in error (sync_run_id still under data); on a standby control plane it refuses with refusal_code: "standby_control_plane" and creates no run operator, agent
system_gitops_get_sync_run Fetch one sync run with the diff payload + proposed change set operator, agent
system_gitops_get_drift_report Per-repository drift summary (rows that diverge from declared state) operator, agent
system_gitops_apply_proposal Apply a proposed change set — the proposal must already be approved, AND the call is approval-gated under system.gitops_apply_proposal (Ai::AutonomyGate, replayed by Ai::Executors::DeferredToolCall): when policy requires approval it answers pending: true and applies nothing until an operator approves operator, agent

Permissions: not one family — read the map in SystemFleetTool::ACTION_PERMISSIONS, not this heading. system_gitops_get_repository / system_gitops_list_repositories gate on system.gitops.read (IMP-b1191457a091 — the same name their REST twin uses, because these two return the vault_credential_path projection). system_gitops_get_sync_run / system_gitops_get_drift_report gate on system.modules.read; system_gitops_register_repository / system_gitops_sync_repository / system_gitops_apply_proposal gate on system.modules.update. There is no system.gitops.view / .apply permission — the registered names are read / write / sync / reconcile (server/lib/powernode_system/engine.rb).

Disk image CI publication management

Action What it does Audience
system_list_disk_image_publications List publications for a NodePlatform (filterable by status / arch / git_sha) operator, agent
system_list_disk_image_webhooks List webhook rows (per-pipeline; the URL embeds the webhook UUID). Carries no secret_preview (IMP-27cc7dceb97b) — the operator UI reads that from GET /api/v1/system/disk_image_webhooks, which gates on system.disk_image_webhooks.read rather than this action's system.modules.read, so it is not a drop-in fallback for every caller operator, agent
system_set_default_disk_image_publication Flip the platform's default-boot publication to a different row operator, agent
system_set_disk_image_retention Set per-NodePlatform retention_count (count-based; 7-day grace fixed) operator, agent
system_list_disk_image_publications (also exposed inline) Operator-curated listing for the UI's publications panel operator

Permissions: system.disk_image_publications.{view,set_default,retain}, system.disk_image_webhooks.view

See DISK_IMAGE_CI.md for the operator-facing workflow that uses these actions in concert with bootstrap_disk_image_ci + provision_disk_image_webhook.

Native module-build batches

system_dispatch_module_build_batch, system_get_module_build_batch and system_cancel_module_build_batch are described in the machine catalog. The recovery verb:

Action What it does Audience
system_readvance_module_build_batch Re-advance ONE batch whose member build finished while its entry is still dispatched (stalled: true on system_get_module_build_batch) — the lease sweep's readvance backstop, on demand, for when the hub-worker cron that normally runs it is down. Calls the sweep service itself, so it does exactly what the sweep would: a completed build is signed + published (and promoted, unless shadow or withheld), a failed one retried or failed, and still-queued members of the batch dispatched. Like the unattended sweep, it publishes and promotes without the release-promote autonomy gate — deliberate, since it is the sweep's own step run on demand; the kill-switch and control-plane gates still apply. Refused under the account kill switch or on a standby control plane; a batch with nothing stalled, not in dispatched/awaiting_signature/publishing, or already being advanced is a no-op (readvanced: false + reason). Requires system.module_builds.readvance (admin / owner / manager, like system.module_builds.cancel) operator

Waiting instead of polling

Three read-only long-polls replace polling the database. system_get_task and system_get_module_build_batch take wait_seconds; system_wait_for holds a rollout.

Action What it does Audience
system_get_task + wait_seconds Holds until the task is finished? (complete, failed, aborted or cancelled) operator, agent, instance
system_get_module_build_batch + wait_seconds Holds until the batch is finished? (complete, partial, failed or cancelled) operator, agent, instance
system_wait_for (module_version_id, environment) Holds until every running node in the environment that carries the module reports the version's oci_digest in its heartbeat running_module_digests; an environment with no such node is not converged. Returns converged, instance_count, converged_count and the pending nodes with the digest each runs operator, agent, instance

wait_seconds is clamped to the server cap (SystemFleetTool::WAIT_MAX_SECONDS, 45s) and polls every 2s; a value that is not an integer is refused, naming the parameter. The cap is a conservative margin, not a measured limit: nothing in this repo times out the endpoint, but the shortest MCP read timeout in code is the 60s of the platform's outbound legacy HTTP client (its streamable client uses 300s), and a hop in front of the platform is not visible from here. An expired wait is a success carrying timed_out: true and the current snapshot, never an error; call again to keep waiting. Each wait holds one Puma thread, so at most a quarter of RAILS_MAX_THREADS (WAIT_CONCURRENCY, 4 by default) may wait at once per process; past that a call that is not already done is answered with a single check, timed_out: true and wait_degraded: true. Absent or 0, the two getters answer at once with their old reply. system_wait_for defaults to the cap, and 0 checks once; a node that reports the digest but has gone silent (no heartbeat within NodeInstance::HEARTBEAT_STALE_AFTER) is listed as pending with stale: true, not counted converged. All three need only their read permission (system.infra_tasks.read, system.module_builds.read, system.modules.read); an instance principal reaches them by an MCP tool grant naming them (system_grant_instance_mcp_tools), since none is destroy-shaped.

Read-only node inspection

system_inspect_node diagnoses one node through its own on-node agent, with no shell. It is the routine sibling of system_out_of_band_exec (human-only, approval-gated, ssh) and shares no code path with it. It queues a probe.node_inspect task running one of seven fixed collectors and long-polls for the result.

collector Runs (exact argv) Arguments
wg_status wg show <interface> (never dump, never all; private and preshared key lines are dropped) interface
routes ip vrf show; ip -4 route show table all; ip -6 route show table all; ip -4 rule show; ip -6 rule show none
nft nft list ruleset or nft list chains scope (ruleset default, chains)
journal journalctl -u <unit> -n <lines> -r --no-pager -o short-iso (-r: newest first, so a bounded read keeps the newest; returned oldest-first) unit, lines (1-500, default 100)
unit systemctl --no-pager cat <unit> (unit file and drop-ins) unit
caps systemctl show -p MainPID --value <unit>, then the Cap* and NoNewPrivs lines of that PID's /proc status, decoded to names unit
file_stat no command: type, size, mode, owner, mtime and sha256 of a file, never its contents path

A collector takes only its own arguments; another collector's argument is refused, not ignored. unit is a full systemd unit name with its type suffix and must exist on the node (systemctl show -p LoadState); interface is at most 15 characters of letters, digits, -, _ and . and the words all and interfaces are refused. Every argument is validated by System::NodeInspection before a task row exists, by System::Task on any row minted another way, and again by the agent (taskguard), which is authoritative; a spec keeps the server and Go lists identical.

file_stat path policy. An allow-list of trees (/etc, /usr, /boot, /persist/var/lib/powernode, /persist/cache/modules, /var/lib/powernode, /run/powernode), an absolute canonical path (no .., ., doubled slashes, spaces or control characters; at most 4096 bytes), and a deny of secret locations inside it: shadow, pki, private, secrets, credentials, .ssh, wireguard, key/token/password-named files and *.key, *.p12, *.env. An allow-list rather than a deny-list because a deny-list is open by default; the sha256 of a low-entropy secret is an offline oracle for it, so secret locations are refused even though no contents are read. /proc (so /proc/<pid>/environ), /sys, /dev, /root, /home, /tmp and /persist/volumes are outside the allow-list. The path is symlink-resolved on the node and the resolved path is judged by the same rule and must stay under the node root, so a link cannot lead out; the file is then opened with openat2 (RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS), so a component swapped for a symlink after the check is refused by the kernel rather than followed.

Network and FUSE mounts are refused. A path held by a mount whose type is nfs*, cifs, smb*, fuse* (fuse.sshfs, fuseblk, ...), 9p, ceph, glusterfs or sshfs (deepest mount wins, per /proc/self/mountinfo; an unreadable mount table refuses) is not touched: /var/lib/powernode hosts NFS storage mounts, and a read on a dead export blocks in D-state. file_stat also runs under a 20s deadline and returns a refusal when a path does not answer. The agent runs one task at a time, so one wedged path would otherwise hold every later task for the node (deploys included); the abandoned read leaks until the mount answers, and after four such leaks new file_stat calls are refused until they drain.

Bounds and scrubbing. Every command is read through a bounded runner: at most 256 KiB of stdout is ever held, and the command is killed once that is exceeded (the result says truncated). Journal lines are capped by the argument and again on the returned text; each collector's result is capped at 64 KiB (the journal keeps its newest lines). On the node all text passes through scrubSecrets, fed by the credential shapes of System::ShellOutputSanitizer (name=value, *_KEY/*_TOKEN/*_SECRET/*_PASSWORD env names, URL userinfo, Authorization/Bearer/Basic/Token values, --password v style flags and login ... -p v, curl -u user:pass, bare JWTs, provider tokens, PEM private keys); the raw result is stored in the task's completed event, so the agent scrubs before sending. The stored result is redacted again through System::StoredOutputRedactor on this surface.

Who may inspect what. A user principal needs system.infra_tasks.create and may inspect any instance of its account. An instance principal may inspect only itself: instance_id may be omitted, and naming any other instance is refused on the server, before a task exists, keyed on the principal (not the verb) and recorded as a system.mcp_node_inspect_refused fleet event; a restricted principal with no node identity is refused. The verb is declared mutating: true (it inserts a System::Task and returns root diagnostics, so it is kept off the read-only allowlist an exported agent gets) but not destructive, and it goes through the same governance row the REST task-creation gate reads, system.task.probe.node_inspect, which ships auto_approve (runs inline): an operator who tightens it to require_approval gets the pending envelope and block refuses it. The instance must be running or starting; a stopped, stopping, pending, provisioning or rebooting node is refused instead of taking a task it would run at its next boot. An unknown instance answers with a literal Couldn't find System::NodeInstance message, no account id or SQL.

Waiting. wait_seconds defaults to the cap (45s) since the caller wants the answer; on expiry the reply is a success with timed_out: true and the task_id, and system_get_task (wait_seconds, include_events) reads the same task later. 0 returns the task_id at once. A collector whose tool fails on the node is a completed result with ok: false and the reason; an argument the agent refuses (a unit that does not exist) is an error carrying the reason and the task_id.

Governed unit restart

system_restart_unit (instance_id, unit, reason) restarts ONE systemd unit that a module composed on an instance, through the node's own agent (an agent restart task, unit-scoped), with no root shell. It is approval-gated on system.task.restart, the same category POST /api/v1/system/tasks reads and which ships require_approval: the reply is pending: true with an approval_request_id and nothing is queued until an operator approves; when the approval lands the generic replay executor re-invokes the verb as the original principal, which creates exactly one task. It is not restart_after_update, which creates the same task shape but never meets the gate.

Check Rule
reason Required, non-blank, at most 500 characters; recorded on the task and in the audit log (system.instance.restart_unit, with the unit, task id and requester); it reaches the approver through the request's filtered params, never the approval description
unit The full name, powernode-<module-id>-<service>.service, and it must be one of the services of the modules attached to that instance's node (System::UnitRestartService.composed_units, the same union the node API serves the agent). An unknown unit, or one composed on another instance, is refused
Namespace Anything outside powernode-* is refused; the node agent's own unit and its variants (powernode-agent*) are refused with the message that agent restarts stay out-of-band
Self-hosting node On the node named by self_hosting_node_id every unit is refused (INV-1 is node-scoped). With the setting unset the fence cannot tell, so it fails closed for the services the control plane runs on (rails*, postgres*, pg-replica, redis, vault, traefik, restore-dynamic, sidekiq, worker-web, caddy, by the shipped manifests' service names), naming the setting; other units are not affected. There is no relaxation switch; use the out-of-band path
Instance Must be running or starting, and its agent must be reporting (never-reported and went-silent instances are refused)

Every check runs at request time (before anything is parked) and again on the approved replay, since the unit may have stopped being composed, or the node become the control plane's host, while the request was parked; the replay then completes with the refusal and creates no task. The agent's taskguard validators (powernode- prefix, an installed unit file) remain the second layer. Requires system.instances.control. Declared mutating and destructive (the class of system_reboot_instance and system_stop_instance) and denied outright to every instance principal by Mcp::Principal::DESTRUCTIVE_TOOL_PATTERNS (*system_restart_unit*); the gate context and the verb refuse an instance principal independently of that overlay.

Governed runtime unit drop-in

system_apply_unit_dropin (instance_id, unit, name, directives, revert) writes ONE runtime systemd drop-in, /run/systemd/system/<unit>.d/zz-operator-<name>.conf, through the node's own agent (a unit.dropin task), then runs systemctl daemon-reload. /run is tmpfs, so a reboot reverts it. revert: true removes zz-operator-<name>.conf and no other file, then reloads. It never restarts the unit, but resource limits apply at once: systemd re-applies MemoryMax, CPUQuota and TasksMax to the RUNNING unit on daemon-reload, so they take effect immediately (a MemoryMax below the unit's current memory use bites at apply time). The other directives take effect only at the unit's next restart, which is system_restart_unit, governed separately. The reply's note says the same.

It is human-only and approval-gated on its own category, system.instance.unit_dropin (declared require_approval, agent-less at scope global, the same shape as system.instance.out_of_band_exec). The reply is pending: true, requires_human_session: true with an approval_request_id; nothing is written until a person approves in their own session, and no policy row or tool door can approve it. The approved replay re-invokes the verb as that person, so every check runs again and exactly one task is created.

Every directive the verb admits can only limit the unit or narrow a confinement relaxation it already has; none can add code, identity or privilege.

Check Rule
unit Exactly system_restart_unit's rules, from the same code (System::UnitRestartService#target_refusal): a composed powernode-* unit on that instance, never the agent's own, every unit refused on the node named by self_hosting_node_id (and the control plane's critical services while that setting is unset), and a running instance whose agent is reporting
name ^[a-z0-9-]{1,32}$; it is the only variable part of the file name
directives A list of {key, value}, at most 32, each key at most once, required unless revert is true (and empty on a revert). Allowed keys only: MemoryMax (bytes with an optional K/M/G/T suffix, 1% to 100%, or infinity), CPUQuota (a whole percentage), TasksMax (a count, 1% to 100%, or infinity), LimitNOFILE (N or SOFT:HARD, soft not above hard, or infinity), AmbientCapabilities and CapabilityBoundingSet, ReadWritePaths (below)
Capabilities Space-separated CAP_* names that must be a subset of the unit's own capability set: the set the agent renders into its capabilities.conf, resolved on the server from the module's config.security (ceiling and privileged) and the service row by the agent's own rule, and read back from that file on the agent. The unit's set is REPLACED, so the drop-in can only narrow it. An empty value (zero capabilities) is always accepted. When the set cannot be resolved (a privileged module, which renders no capabilities.conf, or a service declaring capabilities outside its module's ceiling) only the empty value is accepted
ReadWritePaths Rendered with systemd's - in front of every entry, so a path that does not exist is ignored instead of failing the unit's start. Without + the path is the host's, also for a RootDirectory= unit, and the host path is what is checked. Space-separated absolute, clean paths strictly beneath /persist, never at, beneath or above (an ancestor such as /persist/var/lib) the agent's own state and trust material: /persist/var/lib/powernode (state, PKI, module-signing keys, staged compositions), /persist/cache (module and boot-image blobs), /persist/etc (identity, module-signing configuration), /persist/lint-discovery, /persist/powernode-traefik (ingress certificates and routers), /persist/dev. /run/powernode is not allowed at all: everything the agent keeps there is its own. systemd's - and + path prefixes are refused (every path must start with /)
Refused Every other key, including Environment= ("not settable through this verb; env tuning needs manifest-declared tunables": a denylist of dangerous names such as LD_PRELOAD, NODE_OPTIONS, BASH_ENV or PATH is incomplete by construction), Exec*, User, Group, DynamicUser, SupplementaryGroups, NoNewPrivileges, PermissionsStartOnly, EnvironmentFile, LoadCredential and anything else that names a file; in ANY key or value, a control character (newline, CR, NUL, tab), a non-ASCII character, a backslash (a trailing one continues the line), [ or ] (a section header), a double quote, and % (a systemd specifier) outside the percentage grammars

The file is rendered from the validated pairs under a single [Service] header. The node agent validates every field again independently (agent/internal/runtime/tasks/handlers/unit_dropin.go; the two sides share one table of cases), refuses a unit it did not generate, checks capability lists against the capabilities.conf it rendered for that unit, and writes through an O_NOFOLLOW directory fd with a temp file that is fsynced and renamed into place, so a symlink planted at <unit>.d or at the target is refused and a failed write leaves the previous file untouched. The audit row (system.instance.unit_dropin) is written in the task's transaction and carries the rendered file as a diff; the control plane cannot see a file already on the node, so the diff's "before" side is labelled untracked. POST /api/v1/system/tasks refuses the unit.dropin command outright, and System::Task refuses a unit.dropin row this verb did not mint. Requires system.instances.control. Declared mutating and destructive and denied outright to every instance principal by Mcp::Principal::DESTRUCTIVE_TOOL_PATTERNS (*unit_dropin*); the gate context and the verb refuse an instance principal independently of that overlay.

What the approver sees. The Autonomy approval queue (ApprovalQueuePanel, the expanded row's "Request Data" block) and get_approval_request both show the request's request_data through Ai::SensitiveParams, which carries the call's parameters, every directive key and value included; none of them is secret-shaped now that Environment= is refused. The approval description names the unit, the instance and the drop-in name.

ReadWritePaths on the node. The server checks each path as a string only: it cannot see the node. The agent also walks every entry from / with lstat, one component at a time, and refuses any symlink component (dangling, looping, in the middle or at the leaf), so the path systemd makes writable is exactly the one checked. A path that does not exist yet is allowed (the rendered - lets the unit start without it) only when its nearest existing ancestor is a directory owned by root and not writable by group or others; otherwise whoever can create the missing name later decides what it is. An entry that exists must be a directory (a regular file, possibly a hard link, is refused) whose parent meets the same rule.

Capabilities are checked against capabilities.conf only. The subset rule reads the set the agent rendered into <unit>.d/capabilities.conf. A drop-in a module ships in the same directory that sorts after it and narrows the set further would be undone by an operator list that is still a subset of capabilities.conf, since zz-operator-* sorts last. No shipped module has such a drop-in today.

Caveats. The ReadWritePaths node check is point-in-time: systemd resolves each path again when the unit starts, so a change between the apply and that start is not seen. The ownership rule constrains non-root units only: a sandboxed root unit that already holds a writable grant on an ancestor can still replace the entry after the check. A staged next-boot composition (pending-compose.json on a pivot node) is not consulted: the drop-in lives in /run, so the next boot removes it whether or not that boot composes a staged set, and the verb neither refuses nor warns while one is staged. The daemon-reload the verb runs also loads any unit files a staged composition has already written under /etc, so a later restart of such a unit (a crash restart included) can pick them up before that boot.

system_clear_ssh_host_key (instance_id, reason) clears ONE instance's recorded SSH host key (System::SshHostKeyWriter.clear!), the recovery for a node whose key changed and whose agent never heartbeats again: every platform SSH path is strict against a recorded key and out-of-band exec requires one, so a stale key locks out the break-glass diagnostic for exactly that case. Clear only: there is no pin verb, because host key material does not travel through tool arguments; the node's agent records its own key on its next heartbeat. It is human-only and approval-gated on its own category, system.instance.ssh_host_key_clear (declared require_approval, agent-less at scope global). The reply is pending: true, requires_human_session: true; nothing is cleared until a person approves in their own session, and that person is the actor on the system.ssh_host_key.cleared audit row (previous fingerprints, the reason; never a key). reason is required (1 to 500 characters). A blank reason, an unknown instance or one with no recorded key is refused before parking. The approved reply says what the next connection will do under the current system.ssh.require_host_key setting, and that out-of-band exec stays refused until the node's next heartbeat records a key. Requires system.instances.control. Declared mutating and destructive and denied outright to every instance principal (*system_clear_ssh_host_key*); see docs/design/ssh-host-key-verification.md.

CI worker provisioning

Action What it does Audience
system_provision_ci_worker Mint a Worker row (role ci_worker). Returns the worker record only — the plaintext token is not returned on the MCP surface (a tool result is persisted with the conversation and forwarded to the model provider, IMP-27cc7dceb97b). Fetch it once via POST /api/v1/system/ci_workers/<id>/rotate_token operator
system_terminate_ci_worker Tear down a CI worker row (Gitea deregistration is operator-managed) operator
system_list_ci_workers List CI workers + their last seen-online timestamp operator, agent
provision_ci_worker (parent platform alias) Same as system_provision_ci_worker; legacy name from the parent platform tool registry operator

Permissions: system.ci_workers.{view,provision,terminate}

Storage assignments + volumes

Action What it does Audience
system_list_volumes List System::Volume rows (filterable by storage_class) operator, agent
system_get_volume Fetch one volume row with its assignment + provider state operator, agent
system_create_volume Create a Volume (logical handle; provider materialization is async) operator, agent
system_update_volume Edit metadata (description, tags) operator, agent
system_delete_volume Remove a volume (refuses while assigned) operator, agent
system_attach_volume Attach a Volume to a NodeInstance operator, agent
system_detach_volume Detach without destroying operator, agent
system_test_nfs_export Probe an external NFS export's reachability + auth before creating an assignment operator, agent

Volume snapshots + restore (project data protection, APO-5 / DR-2):

The platform's only backup used to be its own database; a project's volumes had no snapshot and no restore. Provider support is optional and declined rather than faked — a provider with no snapshot primitive refuses instead of recording a restore point that does not exist. See STORAGE_SUBSYSTEM.md.

Action What it does Audience
system_snapshot_volume Snapshot a ProviderVolume through its provider; refuses where unsupported operator, agent
system_list_volume_snapshots List a volume's snapshots, newest first (reconcile:true checks each against the provider) operator, agent
system_delete_volume_snapshot DESTROYS a restore point — approval-gated (system.volume_snapshot_delete); provider delete first, row dropped only on confirmation operator
system_restore_volume_snapshot Restore from a completed snapshot; read restored_in_place (false ⇒ a new volume holds the data; swap_into_place:true swaps it onto the source's instance and device) operator

Permissions: system.volumes.snapshot (create), system.volumes.read (list), system.volumes.delete (delete), system.volumes.manage (restore). Delete is additionally approval-gated: it parks under system.volume_snapshot_delete (an operator-scope policy row — require_approval, written by System::Governance::PolicyReconciler alone since IMP-10e4f6c3bcd2: on a first boot through the seed orchestrator's reconcile pass, on every later boot through rails-start.sh, and on demand via rake system:governance:reconcile; tunable in the Autonomy modal's storage domain) and is replayed by Ai::Executors::DeferredToolCall as the original principal once an operator approves — a {pending: true} response means nothing was deleted.

Storage migration (zero-downtime data moves between providers):

Action What it does Audience
system_list_storage_migrations List in-flight + recent migrations operator, agent
system_get_storage_migration Detail one migration with progress + steps operator, agent
system_migrate_storage_component Initiate a migration (operator-supplied source/destination + cutover plan) operator
system_approve_storage_migration Approve a proposed migration (when the chain composer surfaces one for review) operator
system_cancel_storage_migration Halt an in-flight migration safely operator
system_report_storage_migration_progress Worker-side progress reporting (worker API; not operator-facing) worker
system_get_storage_recommendations Read the architect's storage-class recommendations for a workload type operator, agent
system_update_storage_recommendations Tune the recommendation matrix operator

Permissions: system.volumes.{view,create,update,delete,attach,detach}, system.storage_migrations.{view,initiate,approve,cancel}, system.storage_recommendations.{view,update}

Provider catalog

Action What it does Audience
system_list_providers List configured providers (AWS, GCP, Azure, LocalQemu, …) operator, agent
system_get_provider Fetch one provider with its region/instance-type catalog operator, agent
system_update_provider Edit provider config (credentials reference, region allowlist) operator

Permissions: system.providers.{view,update}

Ingress + public TLS (VIP → port-map → ACME → reverse proxy)

Backed by Ai::Tools::SystemIngressTool (wraps the ExposeServicePublicly / AcmeCertificateProvision / ReverseProxyCompose skill executors). See INGRESS_TLS_GUIDE.md for the operator-facing workflow.

Action What it does Audience
system_expose_service_publicly Expose a service end-to-end: create/reuse an SDWAN VIP, port-map it on the hub, provision a TLS cert for the hostname, regenerate the reverse proxy operator, agent
system_set_service_backends Declare a published service's backend set — the list IS the set (upsert by address, remove what is absent, [] clears back to the legacy single backend) with an explicit backend_port per member, plus per-service load-balancer overrides. Approval-gated under system.service_backends_update (require_approval by default) — pending: true means the set is NOT yet written. system_get_service reports out_of_rotation: true when every declared member is draining (no router is rendered for it) operator, agent
system_acme_provision_certificate Issue an ACME TLS certificate for a hostname (dns-01 / http-01 / tls-alpn-01); stored as a System::AcmeCertificate operator, agent
system_acme_create_dns_credential Create + verify a DNS-01 provider credential (cloudflare, route53, gcloud, digitalocean, hetzner, porkbun, ovh) operator, agent
system_acme_get_certificate Fetch a single System::AcmeCertificate (status / expiry / SANs) operator, agent
system_acme_renew_certificate Re-run issuance for an existing certificate operator, agent
system_acme_revoke_certificate Revoke a certificate (irreversible; reason e.g. key_compromise) operator, agent
system_reverse_proxy_compose Regenerate the Traefik dynamic config so the platform serves an already-valid issued certificate operator, agent

Permissions: system.ingress.manage, system.acme.manage

system_acme_provision_certificate does create + issue in one call; the finer-grained get / renew / revoke wrappers and create_dns_credential are backed by Ai::Tools::SystemAcmeTool. See runbooks/acme-issuance.md for the full lifecycle.

Storage ownership (chown lifecycle)

Backed by Ai::Tools::SystemStorageOwnerTool. Manages StorageAssignment ownership (service_user / operator / nobody / root) and tracks the on-node chown that applies it.

Action What it does Audience
system_assign_storage_owner Assign a StorageAssignment's owner (kind + service user); dispatches the on-node chown operator
system_list_storage_assignments_by_owner Audit query — filter assignments by owner_kind, service_user_username, chown_state, or node_instance_id (e.g. "which assignments still use owner_kind=root?") operator, agent
system_storage_chown_status Inspect chown state of one assignment — current state, previous_uid/gid, task id, timestamps, recorded error operator, agent
system_storage_chown_retry Re-dispatch a failed / manual_required chown (resets chown_last_error → pending); force_complete: true escape hatch for operator-handled external mounts operator

Permissions: system.storage_owner.{assign,view} (chown status/retry covered by the owner-management grant).

GPU + inference

Backed by Ai::Tools::SystemFleetTool. Locate GPU-bearing capacity and stand up an inference runtime.

Action What it does Audience
system_find_node_with_gpu Find live NodeInstances exposing a GPU/accelerator; filter by gpu_type, min_gpu_memory_mb, minimum GPU count operator, agent
system_list_instance_types_by_gpu List provider instance-type SKUs that carry a GPU (filter by gpu_type + min count) — pick a SKU before provisioning operator, agent
system_deploy_inference_server Deploy an inference runtime (ollama) onto a GPU node: assigns gpu-nvidia-runtime + inference-ollama modules and registers an Ai::Provider so the endpoint is consumable operator, agent

Permissions: system.instances.view (discovery) + system.instances.provision (deploy).

Agent-fleet + agent-to-agent (A2A) substrate

Backed by Ai::Tools::SystemFleetTool. The L0–L3 substrate for running agents on fleet instances: isolation-tier selection (L0), per-instance platform-MCP grants (L2), agent-to-agent capability tokens + grants (L2.5/A2A), and full agent-fleet orchestration missions (L3). All capability surfaces are default-deny.

Action What it does Audience
system_list_isolation_tiers L0 — list isolation tiers an agent deployment can request (native / gvisor / kata / firecracker / vm) with their Docker runtime + K8s RuntimeClass mapping operator, agent
system_grant_instance_mcp_tools L2 — grant an instance-agent the platform-MCP tool-name glob patterns it may invoke (default-deny until granted) operator
system_discover_peers A2A — list online, operator-enabled agent peers in the account and the skills they offer (capability discovery) operator, agent
system_grant_instance_peer_skills A2A — grant an instance-agent the peer skill-name glob patterns it may invoke on other instances (default-deny) operator
system_mint_peer_capability_token Always refuses (IMP-27cc7dceb97b) — the envelope+signature pair is signing material and an MCP result is persisted with the conversation and forwarded to the model provider. Use system_authorize_peer_call to check the policy; system_launch_agent_fleet for a cross-instance call (token minted server-side into the delegation descriptor); POST /api/v1/system/node_instance_peers/<id>/execute for a self-edge call (needs system.peers.execute) operator, agent
system_authorize_peer_call A2A — three-gate, default-deny authorization decision for a peer skill invocation (caller granted + target online/enabled + skill offered) agent
system_launch_agent_fleet L3 — launch an agent-fleet orchestration mission: provision a fleet of agent-instances, grant L2 + L2.5 capabilities, delegate work operator
system_agent_fleet_status L3 — inspect an agent-fleet mission: status, current_phase, member/assignment counts, aggregated report, reaped count operator, agent

Permissions: system.a2a.{discover,grant,authorize}, system.agent_fleet.{launch,view}, system.instances.grant_mcp_tools.

SDWAN-adjacent composition (system_*-prefixed)

These two actions are system_*-prefixed (not system_sdwan_*) but route through Ai::Tools::SdwanTool — they compose multiple SDWAN primitives into one operator-facing call.

Action What it does Audience
system_multi_tenant_isolation Provision a fully-isolated SDWAN network slice for a single tenant — dedicated overlay with its own VRF + isolated iBGP RIB, non-overlapping /64, default-deny nftables baseline operator
system_service_discovery_compose Make a backend service discoverable across the fleet over the overlay — provisions a VIP (auto-advertised via iBGP) + a VIP-backed front end operator, agent

Permissions: system.sdwan.networks.manage (composition wraps the underlying network/VIP grants).

Platform deploy + maintenance (Decentralized Federation surface)

Action What it does Audience
system_deploy_platform Deploy a standalone (sovereign) Powernode platform, or return the deployment wizard payload. Federated spawns are refused on this surface — see below operator
system_platform_maintenance Concierge-bound skill — surface fleet-wide maintenance posture + scheduled windows operator, agent
system_platform_resilience Concierge-bound skill — fleet-wide resilience posture + incident recovery suggestions operator, agent

Permissions: system.platform.deploy (deploy), system.platform.read (maintenance), system.platform.scale (resilience) — per SystemFleetTool::ACTION_PERMISSIONS. The latter two are bound to System Concierge. (Note: the auto-generated catalog at docs/reference/auto/mcp-tools.md prints the tool class's REQUIRED_PERMISSION floor, system.nodes.read, for every action — the per-action entries above are what actually gates these calls.)

See docs/federation/SPAWN_MODES.md for the spawn-mode comparison.

Federated spawns do not go over MCP. A federated deploy mints a single-use acceptance token, and an MCP tool result is forwarded to the model provider and persisted with the conversation — so system_deploy_platform refuses mode: "federated" and names the operator path in the error (IMP-c0687cfb3a05). Operators deploy federated platforms over REST:

  • POST /api/v1/system/platform/deployments (permission system.platform.deploy) — runs the same System::PlatformDeploymentOrchestrator and reveals acceptance_token exactly once in its HTTP response. This is what the Deploy Platform wizard submits to.
  • POST /api/v1/system/federation/children/spawn — the lower-level spawn endpoint, likewise unaffected.

system_deploy_platform remains the MCP path for standalone deployments and for fetching the wizard payload.

Additional lifecycle actions

Action What it does Audience
system_destroy_instance Tear down a NodeInstance immediately (skips drain — operator must accept blast radius) operator
system_drain_instance Cordons and STOPS the instance — flips a ready pool member to pool_state=draining (which the allocator reads), then stops the VM through System::InstanceControlService, the same choke point system_stop_instance uses. Requires system.instances.control; refuses an instance on this plane's own hosting node; a refused or failed stop comes back as an error, not as a drain. Nothing relocates in-flight work first — do that BEFORE calling it. The drain_* config markers and timeout_seconds are gone (IMP-f4fe1ed1ec1e) — see node-provisioning.md Phase 5 operator
system_cordon_instance Cordon-only (unschedulable) mode — the reversible half of the drain. Marks the instance unschedulable WITHOUT stopping it: a ready pool member is fenced out of the allocator (pool_state=draining, the one state InstancePoolService#acquire! never picks and the reaper never touches — so pool acquires, CI runner leases and agent-fleet missions stop landing on it), a claimed member is marked only (and fenced rather than re-admitted when its consumer returns it to a reuse_without_reset pool), a non-pool instance is marked only, and on a deployment replica the replica reconciler honours the marker — out of the live count (a replacement is provisioned), first scale-in victim. Records who/why/when under the instance's cordon config key (visible on system_get_instance). Requires system.instances.control; approval-gated under system.instance_cordon (require_approval by default) — a pending: true envelope means it is NOT yet cordoned. See node-provisioning.md Phase 5 operator
system_uncordon_instance Lifts a cordon: clears the marker and hands a member this cordon fenced back to the allocator as ready (with a fresh ready-TTL anchor) — only while it is still draining AND running; refuses to re-admit a stopped member (start it first) and never writes ready over a member that was recycled or errored meanwhile. Gated under the SAME system.instance_cordon category — one operator row governs both directions operator
system_get_silent_instances Audit query — list NodeInstances whose last_heartbeat_at exceeds the silent threshold operator, agent
system_refresh_instance_modules Force the agent to re-reconcile its module set against the platform's view operator, agent
system_module_mark_canary Mark a module as a honeypot canary (any access emits a high-severity FleetEvent) operator
system_recycle_pool Drain + replace all instances in an InstancePool (slice 7 — pool refresh) operator
system_delete_template Remove a NodeTemplate (refuses while in use) operator
system_unassign_module_from_template Drop a module → template binding and, in the same transaction, purge the NodeModuleAssignments it produced (a row another join still needs is re-pointed; hand-authored rows untouched). Returns purged_assignments {count, node_ids} operator
system_delete_module Remove a NodeModule (refuses while assigned) operator
system_delete_node Remove a Node row (refuses if any NodeInstance still exists; cascade delete is system_terminate_instance first) operator

Permissions: the action verb on the matching resource (system.{node_instances,node_modules,instance_pools,…}.{view,destroy,delete,recycle,refresh,mark_canary,drain}).

Fleet autonomy configuration

Backed by Ai::Tools::SystemFleetTool. Sensor thresholds are per-account configuration resolved from System::Fleet::SensorConfig with each sensor's class constant as the fallback — see FLEET_SENSORS.md for the tunable keys and their defaults.

Action What it does Audience
system_get_sensor_config Read a sensor's defaults, stored overrides and effective thresholds (omit sensor to list every configurable sensor) operator, agent
system_update_sensor_config Merge threshold overrides for one sensor; a null value drops an override. Rejects undeclared keys and non-positive values operator

Permissions: system.fleet.read (read), system.fleet.manage (write).

CVE management

Action What it does Audience
system_get_cve Fetch a single CVE row (CVE-id, severity, affected packages, references) operator, agent
system_create_cve Create a CVE row manually (typically driven by the NVD feed worker; operator override path) operator
system_delete_cve Remove a CVE row (audit-cleanup) operator
system_get_cve_exposure Per-NodeInstance exposure detail — which assigned modules ship the affected packages operator, agent

Permissions: system.cves.{view,create,delete}, system.cve_exposures.view. CVE remediation runs through the CVE Responder agent's intervention policies (see FLEET_SENSORS.md §"CVE Responder agent (5 policies)") rather than direct MCP actions.

Vault credential rotation

Action What it does Audience
system_rotate_vault_transit_pepper Rotate the Vault transit pepper used for per-account encryption-key wrapping (DR scenario; rare) operator

Permissions: system.vault.rotate_transit_pepper. See runbooks/vault-credential-restoration.md for the broader rotation workflow.

system_sdwan_* — SDWAN networking + OVN + IPFIX + host bridges + federation accept (73 actions)

Backed by Ai::Tools::SdwanTool. Comprehensive network management.

Networks

Action What it does
system_sdwan_list_networks List Networks in account
system_sdwan_get_network Fetch a Network + its topology summary
system_sdwan_create_network Create a Network with prefix + routing_mode
system_sdwan_update_network Edit metadata (description, routing_mode toggle)
system_sdwan_delete_network Destroy a Network and all its peers/VIPs/rules

Peers

Action What it does
system_sdwan_list_peers List Peers on a Network
system_sdwan_get_peer Fetch a Peer + its current handshake state
system_sdwan_attach_peer Add a NodeInstance as a Peer; allocates /128
system_sdwan_detach_peer Remove a Peer from a Network
system_sdwan_rotate_peer_key Rotate a Peer's WireGuard key in place (same peer id and overlay address); approval-gated under sdwan.peer_key_rotate, returns public-key fingerprints only, denied to instance principals
system_sdwan_get_topology Network-wide reachability + handshake summary

Firewall rules

Action What it does
system_sdwan_list_firewall_rules List rules on a Network
system_sdwan_get_firewall_rule Fetch a rule by id
system_sdwan_create_firewall_rule Create a rule (selector + action + protocol + port_range)
system_sdwan_update_firewall_rule Edit a rule
system_sdwan_delete_firewall_rule Remove a rule

Access grants + user devices

Action What it does
system_sdwan_list_access_grants List active access grants
system_sdwan_create_access_grant Issue a single-use bootstrap URL for a user device (15-min default expiry)
system_sdwan_revoke_access_grant Revoke a grant — cascades to revoke EVERY device on it. Approval-gated (sdwan.access_grant_revoke) — returns pending: true + a deferred_operation_id until approved
system_sdwan_list_user_devices List active UserDevices on a Network
system_sdwan_issue_user_device Convert an access grant + user-side public key into a UserDevice
system_sdwan_revoke_user_device Revoke ONE UserDevice (cuts off that device's VPN access; siblings and the grant are untouched). Approval-gated (system.sdwan_user_device_revoke) — returns pending: true + a deferred_operation_id until approved

Federation peers (slice 11 — live)

Action What it does
system_sdwan_list_federation_peers List federation peers on a Network
system_sdwan_get_federation_peer Fetch a federation peer
system_sdwan_propose_federation_peer Account A proposes peering with Account B (out-of-band — does NOT spawn a child platform; use the children-spawn REST endpoint for that). Approval-gated (sdwan.federation_peer_propose) — returns pending: true + a deferred_operation_id and no peer row exists until approved. Acceptance-token minting is refused on this surface (IMP-3a32dc649043); propose over POST /api/v1/system/sdwan/federation_peers when you need the token
system_sdwan_accept_federation_peer Account B accepts a proposed peering (moves the row to status: "accepted"). Approval-gated (sdwan.federation_peer_accept) — returns pending: true + a deferred_operation_id until approved
system_sdwan_update_federation_peer Update a federation peer (e.g. its priority-ordered endpoints list). The two trust-boundary transitions are approval-gated: status: "accepted" on sdwan.federation_peer_accept, status: "revoked" on sdwan.federation_peer_revoke; other transitions apply inline
system_sdwan_revoke_federation_peer Cancel a federation relationship from either side — cuts cross-instance routing. Approval-gated (sdwan.federation_peer_revoke) — returns pending: true + a deferred_operation_id and nothing is cut until approved. Same category and executor as system_sdwan_update_federation_peer with status: "revoked", so one policy covers every route to a revoked peer
system_sdwan_federation_scan Scan for proposed-but-not-accepted peers (for operator review)
system_sdwan_federation_compose Stand up a federation overlay topology (hub-and-spoke OR full-mesh) across instances — creates one Sdwan::Network, enrolls each member as a peer (hubs publicly_reachable), and compiles the routing plan
system_sdwan_set_data_residency Stamp a peer with a scalar data_residency region/tag (P9.4 enforcement). Approval-gated (sdwan.federation_peer_data_residency, seeded require_approval) and audited on the peer's trail as federation.peer.data_residency_changed; the operator twin is PATCH /sdwan/federation_peers/:id with data_residency
system_sdwan_get_audit_log Read a peer's per-peer WORM audit log (P9.3)

Permissions: system.sdwan.federation_peers.{view,propose,accept,revoke}

Routing + iBGP

Action What it does
system_sdwan_update_peer_lan_subnets Declare LAN subnets a peer advertises over iBGP
system_sdwan_update_network_routing_mode Toggle network between static and ibgp
system_sdwan_list_subnet_advertisements List active subnet advertisements
system_sdwan_get_routing_summary Network-wide routing table summary

Virtual IPs (slice 3)

Action What it does
system_sdwan_create_virtual_ip Allocate a VIP with primary + failover holders
system_sdwan_list_virtual_ips List VIPs on a Network
system_sdwan_get_virtual_ip Fetch a VIP + its assignments
system_sdwan_update_virtual_ip Edit VIP metadata (failover candidates, name)
system_sdwan_delete_virtual_ip Free a VIP back to the network
system_sdwan_failover_virtual_ip Promote next failover candidate (manual or scripted via skill)
system_sdwan_list_vip_assignments List assignment history (audit trail)

BGP

Action What it does
system_sdwan_get_account_bgp Fetch per-account ASN + BGP global config
system_sdwan_update_account_as_number Set the account's ASN (private 64512–65534)
system_sdwan_get_bgp_sessions List iBGP sessions on a Network with their states
system_sdwan_get_bgp_config_for_peer Fetch FRR config snippet for a peer (debugging)

Route policies (slice 9)

Action What it does
system_sdwan_list_route_policies List policies on a Network
system_sdwan_get_route_policy Fetch a policy by id
system_sdwan_create_route_policy Create a JSONB-statement policy
system_sdwan_update_route_policy Edit a policy
system_sdwan_delete_route_policy Remove a policy
system_sdwan_compile_route_policy Render the FRR route-map + aux objects (audit)

Port mappings

Action What it does
system_sdwan_list_port_mappings List port mappings on a Network
system_sdwan_get_port_mapping Fetch a mapping
system_sdwan_create_port_mapping Map an external :port → internal [<peer-/128>]:port
system_sdwan_update_port_mapping Edit a mapping
system_sdwan_delete_port_mapping Remove a mapping

Host bridges (Phase O1 — slice 9 host topology)

Action What it does
system_sdwan_list_host_bridges List Sdwan::HostBridge rows (per-host bridge allocations)
system_sdwan_get_host_bridge Fetch one bridge by id, with its lifecycle timestamps
system_sdwan_create_host_bridge Allocate a new host bridge (operator or Topology Designer)
system_sdwan_activate_host_bridge Mark a bridge as active so the agent picks it up on next reconcile
system_sdwan_release_host_bridge Release a host bridge — drains by default, force: true removes it immediately

Permissions: system.sdwan.host_bridges.read (list/get) and system.sdwan.host_bridges.manage (create/activate/release).

Releasing drains by default. Without force, the bridge moves to draining: it stays in the compiler's emit set and keeps its short_id reserved so in-flight taps finish before the id can be reissued. Sdwan::HostBridgeReaper (daily, System::HostBridgeReaperJob) then sweeps draining rows past a 24h grace window to removed, which is what makes the default safe — before IMP-53a5c597ec8c there was no such sweep, the state machine had no edge out of draining, and a non-forced release left the bridge serving on the host indefinitely. force: true skips the window and marks the bridge removed at once — the compiler stops emitting it immediately and anything mid-provision against it loses its bridge name.

Two states short-circuit the drain, on every surface, because the drain edge is wrong for them: a pending bridge was never applied to the host, so draining it would instead make it compilable and cause the agent to create the bridge you just released — it goes straight to removed. An already removed bridge is left untouched rather than silently no-opping. The REST route (DELETE /api/v1/system/sdwan/host_bridges/:id) carries the identical default and the identical force opt-in; before IMP-53a5c597ec8c it hard-forced unconditionally, so an operator delete skipped a window an agent release honored.

All three write verbs are approval-gated (sdwan.host_bridge_create / _update / _delete) on both the MCP and REST surfaces, so the tier an operator configures actually binds. Under require_approval the call returns pending: true with a deferred_operation_id and nothing is written until an operator approves. An approver reading a host_bridge_delete card should check the operation's force param — the same category covers both the drain and the immediate teardown.

REST parity: GET/POST /api/v1/system/sdwan/host_bridges, GET/DELETE …/:id, and POST …/:id/activate mirror these five actions one-for-one.

OVN logical-network composition (Phase O3 — slice 9 OVN integration)

Action What it does
system_sdwan_create_ovn_deployment Create an OVN deployment on a network (manager + controller + switches)
system_sdwan_delete_ovn_deployment Tear down the OVN deployment + its switches/ACLs
system_sdwan_create_ovn_logical_switch Create a logical switch in the deployment
system_sdwan_delete_ovn_logical_switch Remove a logical switch (refuses while ports are attached)
system_sdwan_create_ovn_logical_switch_port Attach a port to a logical switch (typed: localnet / VIF / patch)
system_sdwan_create_ovn_acl Create an OVN ACL (direction + match + action — fine-grained pod policy)
system_sdwan_delete_ovn_acl Remove an ACL
system_sdwan_list_ovn_acls List ACLs on a logical switch
system_sdwan_compile_ovn_plan Render the full OVN plan (debug + audit before apply)

Permissions: system.sdwan.ovn.{view,deploy,destroy,configure} — composed via Topology Designer agent's sdwan_ovn_compose_topology skill in typical use.

IPFIX flow telemetry (Phase O5 — slice 9 observability)

Action What it does
system_sdwan_list_ipfix_collectors List the account's IPFIX collectors
system_sdwan_get_ipfix_collector Fetch one collector, including is_winning_collector (only the oldest active row is stamped onto the OVS bridges)
system_sdwan_create_ipfix_collector Register a collector endpoint (host + port) for flow export
system_sdwan_update_ipfix_collector Enable/disable a collector — use this, not delete, to stop flow export
system_sdwan_delete_ipfix_collector Remove a collector and every flow sample recorded against it (dependent: :destroy + cascade FK)

Permissions: system.sdwan.ipfix.read (list/get) and system.sdwan.ipfix.manage (create/update/delete) — see Ai::Tools::SdwanTool::ACTION_PERMISSIONS, which is the authority. (The system.sdwan.ipfix_collectors.{view,create,delete} names this table carried previously were never implemented.) Composition typically runs through the sdwan_ipfix_collector_compose skill (Topology Designer).

Delete is not a stand-in for disable: it cascades the collector's flow_samples, taking with it the correlation history System::Fleet::Sensors::SdwanServiceHealthSensor reads. system_sdwan_update_ipfix_collector stops the export and keeps both (IMP-6bbe5c673c38).

kubernetes_* — Phase 2 K3s clusters (5 actions)

Backed by Ai::Tools::KubernetesClusterTool + KubernetesProvisioningTool. Phase 2 ships read + decommission + kubeconfig retrieval; cluster creation is implicit (assigning k3s-server module to a NodeInstance creates a cluster).

Action What it does
kubernetes_list_clusters List clusters in the account
kubernetes_get_cluster Fetch cluster details (status, flavor, api_endpoint, node count)
kubernetes_list_nodes List KubernetesNodes for a cluster (control-plane + workers)
kubernetes_decommission_cluster Cascade-delete cluster + its KubernetesNodes (NodeInstances NOT terminated)
kubernetes_get_kubeconfig Retrieve kubeconfig YAML + api_endpoint VIP

docker_* — Docker daemon management (52 actions)

Backed by 7 tool classes: DockerContainerTool, DockerServiceTool, DockerStackTool, DockerClusterTool, DockerHostTool, DockerImageTool, DockerNetworkVolumeTool. Works on both managed (Powernode-provisioned) and external (operator-registered) hosts. Per memory powernode.docker_mcp_tools.

Containers

Action What it does
docker_list_containers List containers on a host
docker_get_container Detailed info on one container
docker_create_container Create from an image
docker_start_container / docker_stop_container / docker_restart_container Lifecycle
docker_delete_container Remove (force flag available)
docker_container_logs Retrieve logs (tail + since filters)
docker_container_stats Live CPU/mem/network I/O stats
docker_container_exec Execute a command in a running container

Services (Swarm)

Action What it does
docker_list_services / docker_get_service / docker_create_service / docker_update_service Lifecycle
docker_scale_service Scale to N replicas
docker_rollback_service Rollback to previous version
docker_delete_service Remove a service
docker_service_logs Aggregated logs across all tasks
docker_service_tasks List tasks with status + node placement

Stacks (Swarm)

Action What it does
docker_list_stacks / docker_get_stack Inspection
docker_deploy_stack Deploy/redeploy from Compose YAML
docker_delete_stack Remove all services in a stack
docker_adopt_stack Adopt an externally-deployed stack into Powernode

Cluster (Swarm)

Action What it does
docker_list_clusters / docker_get_cluster / docker_cluster_health Inspection
docker_list_nodes List nodes with role + availability
docker_node_promote / docker_node_demote Worker ↔ manager
docker_node_drain / docker_node_activate Drain/resume scheduling
docker_list_secrets / docker_create_secret / docker_delete_secret Swarm secrets
docker_list_configs / docker_create_config / docker_delete_config Swarm configs

Hosts

Action What it does
docker_list_hosts List all Docker hosts (managed + external)
docker_get_host Detailed host info (OS, resources, Docker version)
docker_sync_host Refresh containers/images from daemon
docker_test_host Test connection to a host

Images

Action What it does
docker_list_images List images with tags + size
docker_pull_image Pull from registry
docker_delete_image Remove (force flag available)
docker_tag_image Tag with new repo + tag

Networks + volumes

Action What it does
docker_list_networks / docker_create_network / docker_delete_network Network lifecycle
docker_list_volumes / docker_create_volume / docker_delete_volume Volume lifecycle

Aspirational backlog

.verify/ASPIRATIONAL_MCP.md is the catalog of documented actions that are not registered — verbs a runbook or tutorial names on purpose, with the REST/console workaround at the call site. The count is deliberately not restated here: that catalog is derived from ASPIRATIONAL_VERBS in server/spec/docs/module_docs_mcp_call_signatures_spec.rb and pinned to it by an equality oracle, and a hand-kept second copy of the number is exactly what went wrong before (this paragraph asserted the backlog was empty from 2026-06-04 until 2026-09-01, while two entries sat in FLEET_SENSORS.md). Read the catalog for the current set.

Three wrappers did leave the backlog on 2026-06-04 — system_get_task, system_revert_disk_image and system_update_module_assignment are implemented and registered in platform_api_tool_registry.rb. To add a new aspirational reference, comment the example out, say so in the prose, then add the entry to ASPIRATIONAL_VERBS and the row to the catalog; remove both when the wrapper ships.

Operator runbooks under docs/runbooks/ should reference current registry actions only; cross-validate against this doc + the aspirational catalog before adding a new runbook step.

How to add a new action

  1. Pick the right tool class in server/app/services/ai/tools/ (parent) — extend if it fits a domain; create new if introducing a new resource family.
  2. Implement the action method on the tool class. Return a { success: bool, data: hash, error: string } shape.
  3. Add an entry to platform_api_tool_registry.rb — "my_new_action" => "Ai::Tools::MyTool".
  4. Add action_definitions for the new action describing description, parameters, permission. The MCP server uses these to advertise the tool to clients.
  5. Add a permission row in a migration (db/migrate/<timestamp>_add_<name>_permission.rb).
  6. Run bundle exec rails mcp:generate_tool_catalog — regenerates docs/platform/MCP_TOOL_CATALOG.md (gitignored).
  7. Update this MCP_API_REFERENCE.md with the new action — the manually-curated subset for operators.

Counts

Prefix Unique actions Tool classes
system_* (excl. sdwan) 128 SystemFleetTool + SystemArchitectureCatalogTool + SystemPackageRepositoryTool + DockerProvisioningTool + DiskImageOperatorTool + SystemIngressTool + SystemAcmeTool + SystemStorageOwnerTool + topology / storage tools
system_sdwan_* 73 SdwanTool (incl. OVN + IPFIX + host-bridge + federation accept/compose subsections)
kubernetes_* 5 KubernetesClusterTool + KubernetesProvisioningTool
docker_* 52 7 tool classes (Container, Service, Stack, Cluster, Host, Image, NetworkVolume)
Total code-derived from platform_api_tool_registry.rb — regenerate the machine catalog (rails mcp:generate_tool_catalog) for the authoritative count. For actions documented but NOT registered, read .verify/ASPIRATIONAL_MCP.md rather than a count restated here

Counts are code-derived from platform_api_tool_registry.rb on 2026-06-03. The parent platform's full machine-generated catalog (with parameter schemas) is the authoritative cross-check — regenerate it with cd server && bundle exec rails mcp:generate_tool_catalog.

Related docs

  • SKILL_EXECUTORS.md — skill executors (compose multiple MCP actions into orchestrations); SKILL_EXECUTOR_CATALOG.md is the auto-generated full list
  • FLEET_SENSORS.md — fleet sensors (signals → autonomy actions → MCP action invocation)
  • runbooks/ — operator runbooks (use these MCP actions in concrete workflows)
  • server/app/services/ai/tools/platform_api_tool_registry.rb (parent platform) — source of truth for action-to-tool mapping

Last verified: 2026-06-03 (rev 2)