a73x

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.