eitri gives Claude explicit tools for creating and controlling VMs on a fleet, over two transports:
eitri-mcp (cmd/eitri-mcp), a binary you run yourself. It
is an API client of the control plane plus SSH; it embeds no control-plane or
agent code, and it holds its own SSH user CA.https://api.eitri.sh/mcp, served by the control plane
itself. MCP streamable HTTP, authenticated with a personal access token. No
install, no config file, no CA of your own: a PAT is enough.Both serve the same 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.
Build the binary:
make build
(or go build ./cmd/eitri-mcp)—this produces bin/eitri-mcp.
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):
server_url—required, the eitri API base URL.token_file—required, path to a file holding a personal access token
(mint one in the console Settings page; read at startup, held in memory,
never surfaced in a tool result or error).gate—the SSH-CA jump gate address, <gate-domain>:<port>; the MCP
reaches all VMs by name through it. The host part must match the
gate's host certificate principal (the server's ssh_gate_domain,
which defaults to the ssh_listen host).vm_user—guest SSH user; defaults to ubuntu if omitted.ca_key_path—optional path to this client's persistent user CA
(load-or-create); defaults to a user_ca file next to the config. Its
public key self-registers with the tenant on first gate use, so a fresh
install needs only a PAT—no manual eitri ca upload.tenant—optional. The credential names the tenant (VM connect names are
derived from it), so leave this unset. Set it only when the PAT's user
CA/tenant mapping is ambiguous, e.g. a human or CA belonging to more than
one tenant.The config path can be overridden with --config or $EITRI_MCP_CONFIG;
it defaults to ~/.config/eitri-mcp/config.json.
Register with Claude Code:
claude mcp add eitri -- /path/to/repo/bin/eitri-mcp
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).
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.
persistent: true. There is no TTL and no
reaper—VMs live until something explicitly destroys them. vm_destroy
(exact id or name, no wildcards, no bulk) is the only kill path, and Claude
is instructed to treat it as explicit-only, never automatic cleanup.vm_expose binds a host port and
pipes it to the guest; whoever can reach the host on that port reaches the
service. The tool descriptions say so, so the model treats publishing as a
deliberate act. DNS, TLS certs and routing remain out of scope—the tools
hand back a host address and a port.IMPORTANT—"ready" is not "booted."
vm_create'slifecycle=readymeans 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 startedsshd.vm_create(with the defaultwait: true) accounts for this: it polls forready+ IP, then retries SSH until it connects, then runscloud-init status --waitbefore returning—so a normalvm_createcall only returns once the guest is genuinely usable. But if you reach a just-created VM some other way (e.g. its IP fromvm_list/vm_infoimmediately after creation, orwait: false), it may still be mid-boot and refuse connections for a short window.
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.