docs/mcp.md
Ref: Size: 8.6 KiB History
# MCP: Claude ↔ eitri VMs eitri gives Claude explicit tools for creating and controlling VMs on a fleet. There is one place it speaks MCP: `/mcp`, served by the control plane itself. MCP streamable HTTP, authenticated with a personal access token. No install and no config file: a PAT is enough to connect. Creating VMs additionally needs an SSH user CA you register once with `ca_upload`; every other tool needs only the token. On the hosted plane that endpoint is `https://api.eitri.sh/mcp`. A plane you run yourself serves the same endpoint on its own address, from the same binary — a self-hosted eitri is a self-hosted MCP server, with nothing extra to install. ## Tools | Tool | Purpose | |---|---| | `vm_create` | Create a VM; by default waits for `lifecycle=ready` + IP, then for guest SSH and `cloud-init status --wait` to finish. Takes an optional `network`, the named host network to give the guest a second NIC on; a host serves only the names it advertises, and naming another refuses the create. Returns `network` and `network_ip`—the address that network's own DHCP granted, empty at ready when it has not answered yet. Refused while the tenant has no registered SSH user CA—call `ca_upload` first, or the guest would trust nothing. An optional `cloud_init` is the guest's own user-data, taken verbatim: eitri's own `write_files` and `runcmd` (the CA trust file, the sshd drop-in, the clock drop-in, the root grow) are **prepended** to yours rather than replaced by them, and every scalar you set—`growpart` included—still wins. | | `vm_list` | List all VMs on the fleet (id, name, lifecycle, IP, size, `network` and `network_ip`). | | `vm_info` | Show one VM's state, both addresses, plus a ready-to-use `ssh` command. Where to read `network_ip` back when it was still empty at create. | | `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 port on the VM's host, `protocol` `tcp` (the default) or `udp`; returns the exposure id, both ports, the protocol, the address to dial and the socket'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` or `protocol` disambiguates when one guest port is published twice). | | `vm_destroy` | Destroy a VM by id or exact name. Explicit-only—never called automatically. | | `ca_upload` | Register your SSH user CA's public key with your tenant, with an optional label. Only the public half is sent. A guest trusts the CA set it was created with, so VMs that already exist will not accept certificates from a CA uploaded now. | | `tenant_info` | Show this tenant's setup: registered CAs (fingerprint and label), whether a delegation is live and when it expires, and the gate address. Read-only. | | `delegate_begin` | Get the ephemeral public key eitri will authenticate with, the principal your certificate must carry, and the `ssh-keygen` line that signs it. | | `delegate_complete` | Hand back the signed certificate. eitri can then reach your VMs until it expires. | 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 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 delegate access, which is what lets `vm_exec`, `vm_write_file`, `vm_read_file` and `vm_create`'s boot wait reach a guest. It is two calls and one `ssh-keygen`: 1. `delegate_begin` returns a public key, the principal (`ubuntu`) and the exact command to run. 2. Run it. It signs eitri's public key with **your** CA, on your TTL: ``` ssh-keygen -s ~/.ssh/my-ca -I eitri-delegation -n ubuntu -V +8h eitri-delegation.pub ``` 3. `delegate_complete` with the contents of `eitri-delegation-cert.pub`. eitri holds no signing key at any point. Its half is an ephemeral keypair held in memory, useless without a certificate it cannot produce for itself. The certificate chains to a CA your tenant has already registered, so **VMs created before you delegate accept it too**. The delegation lives in memory only. A control-plane restart drops it and you delegate again — the public key is stable, so that is one `ssh-keygen` and one `delegate_complete`. A `delegate_begin` you never complete is held for an hour and then abandoned, so a certificate signed long after the fact is refused; call `delegate_begin` again for the current key. `GET /api/v1/delegations` reports the expiry; `DELETE` ends it now. Delegation requires the tenant to have at least one registered SSH user CA (`eitri ca upload`), because a certificate signed by a CA your guests do not trust would be refused by them anyway. 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 eitri reaches VMs by name over SSH, with certificates in both directions and no TOFU anywhere. The user signing key stays with you: eitri is delegated a credential, never a key. eitri generates an ephemeral ed25519 keypair per tenant, in memory only, and hands you the public half; you sign it with your own CA, on your own TTL and principals, and post the certificate back. eitri then authenticates as that key-plus-certificate: it 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. No signing key ever exists on the server, the certificate expires, and a restart drops it. Because the certificate chains to a CA the tenant already registered, it is accepted by every guest that trusts that CA — including ones created long before the delegation. Exec without a live delegation is refused with the three-step recipe rather than a generic authentication failure. 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 by eitri's own cloud-config, merged with yours), 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 - Every VM is persistent: a guest lost to a host reboot or a dead hypervisor is booted again. 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. - The tool surface has no host or fleet operations by design—see the tools table above. - A published port is **unauthenticated**. `vm_expose` binds a host port and pipes it to the guest; whoever can reach the host on that port reaches the service. A UDP one goes further: with no handshake to prove where a datagram came from, the host answers the address the datagram claimed, so the port can be aimed at a third party. 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. - The endpoint authenticates with a bearer PAT. Browser connectors (claude.ai) need OAuth, which the endpoint does not speak. > **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.