eitri-mcp: Claude ↔ eitri VMs

eitri gives Claude explicit tools for creating and controlling VMs on a fleet, over two transports:

Both serve the same tools.

Tools

Tool Purpose
vm_create Create a persistent VM; by default waits for lifecycle=ready + IP, then for guest SSH and cloud-init status --wait to finish.
vm_list List all VMs on the fleet (id, name, lifecycle, IP, size).
vm_info Show one VM's state plus a ready-to-use ssh command.
vm_exec Run a shell command in a VM over SSH; returns stdout, stderr, exit code.
vm_write_file Write content to a path in a VM over SFTP (parent dirs created).
vm_read_file Read a file from a VM over SFTP (capped at 1 MiB, truncation flagged).
vm_expose Publish a guest TCP port on the VM's host; returns the exposure id, both ports, the address to dial and the listener's state. Omit host_port to allocate one from 30000–32767.
vm_exposures List a VM's published ports, same shape.
vm_unexpose Stop publishing a guest port (host_port disambiguates when one guest port is published twice).
vm_destroy Destroy a VM by id or exact name. Explicit-only—never called automatically.
register (remote only) Enable an eitri-managed SSH CA for your tenant, so a bare token can reach your VMs. Idempotent.

Deliberately absent: any host or fleet-level operation (enroll, decommission, power management, image/firmware knobs). The worst case from a confused model is VM churn, never fleet damage—and Claude Code's per-tool permission prompts gate every call regardless.

Setup: local stdio

  1. Build the binary:

    make build
    

    (or go build ./cmd/eitri-mcp)—this produces bin/eitri-mcp.

  2. Create ~/.config/eitri-mcp/config.json:

    {
      "server_url": "http://127.0.0.1:8080",
      "token_file": "~/eitri-deploy/eitri-mcp-token",
      "gate": "127.0.0.1:2223",
      "vm_user": "ubuntu"
    }
    

    Fields (see internal/mcpserver/config.go):

    The config path can be overridden with --config or $EITRI_MCP_CONFIG; it defaults to ~/.config/eitri-mcp/config.json.

  3. Register with Claude Code:

    claude mcp add eitri -- /path/to/repo/bin/eitri-mcp
    

Setup: remote endpoint

Mint a personal access token in the console Settings page and point an MCP client at the endpoint with that token as a bearer credential:

claude mcp add --transport http eitri https://api.eitri.sh/mcp \
  --header "Authorization: Bearer $EITRI_TOKEN"

Then call register once. It enables an eitri-managed SSH CA for your tenant, which is what lets vm_exec, vm_write_file, vm_read_file and vm_create's boot wait reach a guest with nothing but a token. It is idempotent—calling it again reports the CA you already have rather than replacing it.

A guest trusts the CA set it was created with, so VMs created before you call register will not accept eitri's certificates. Create new ones.

The endpoint answers POST only; a GET is 405. There are no sessions—each call carries its own credential. vm_create with the default wait: true blocks for as long as the guest takes to boot, and emits MCP progress notifications while it waits (for clients that ask for progress).

Access model

Both transports reach VMs by name over SSH, with certificates in both directions and no TOFU anywhere. They differ only in who holds the user signing key and which path the connection takes.

Local stdio holds its own user CA (ca_key_path, load-or-create) and self-registers that CA's public key with its tenant on first use; eitri never sees the private half. Per connection it signs a short-lived user certificate locally (principal ubuntu) with that CA, fetches the eitri host CA's public key once via GET /api/v1/ssh-ca, dials the configured gate, and opens a tunnel to <tenant>.<vm>:22. Host identity is verified on both hops with ssh.CertChecker against the host CA: the gate's certificate must carry its configured domain as principal, and each VM's must carry the VM's connect name.

The remote endpoint signs with an eitri-managed CA instead. register generates an ed25519 CA for your tenant, stores the private half in the server database, and registers the public half in the tenant's CA set exactly like an uploaded one. Per operation the server signs a fresh throwaway keypair with it (principal ubuntu, five-minute validity, tenant recorded in the certificate's KeyId), reaches the guest's sshd over the VM host's own sync tunnel, and verifies the guest's host certificate under <tenant>.<vm>:22 against the host CA. Nothing long-lived leaves the server, and no certificate is ever handed to a client. DELETE /api/v1/managed-ca destroys the key and removes the CA.

Managed and bring-your-own CAs coexist: a tenant may have both, and enabling one does not affect the other. A tenant with only a BYO CA gets a specific refusal from remote exec—eitri holds no key for that CA and says so.

VMs are addressed by their namespaced connect name, not IP, so recycled IPs and host-key churn are not a concern. The guest trusts the tenant's registered user CAs via TrustedUserCAKeys (provisioned through vendor-data), so no per-VM authorized_key injection is needed. The PAT authenticates API calls only; SSH traffic uses the certificate, and the token itself is never surfaced in a tool result or error.

Semantics

IMPORTANT—"ready" is not "booted." vm_create's lifecycle=ready means cloud-hypervisor is up and the VM has an allocated IP; it does not mean the guest has finished booting Linux, brought up its NIC, or started sshd. vm_create (with the default wait: true) accounts for this: it polls for ready + IP, then retries SSH until it connects, then runs cloud-init status --wait before returning—so a normal vm_create call only returns once the guest is genuinely usable. But if you reach a just-created VM some other way (e.g. its IP from vm_list/vm_info immediately after creation, or wait: false), it may still be mid-boot and refuse connections for a short window.

Testing this yourself

Unit tests (internal/mcpserver/*_test.go, internal/server/mcphttp/*_test.go) cover the tools and the HTTP transport against fake API and SSH seams. The deploy boot-gate (make deploy) drives a whole VM life through the remote endpoint with a bearer PAT: register, vm_create, vm_exec, vm_expose, dial the published port, vm_destroy.