internal/mcpserver/tools.go
Ref: Size: 35.7 KiB History
package mcpserver
import (
"context"
"errors"
"fmt"
"io/fs"
"net"
"slices"
"strconv"
"strings"
"time"
"github.com/a73x/eitri/internal/random"
"github.com/a73x/eitri/internal/server/api/client"
"github.com/a73x/eitri/internal/server/release"
"golang.org/x/crypto/ssh"
)
// api and Exec are the two seams Tools composes; API and Runner satisfy them,
// fakes replace them in tests.
type api interface {
ListVMs(ctx context.Context) ([]client.VM, error)
CreateVM(ctx context.Context, req client.CreateVMRequest) (client.CreateVMResponse, error)
DeleteVM(ctx context.Context, id string) error
ListHosts(ctx context.Context) ([]client.Host, error)
FirstEligibleHost(ctx context.Context, network string) (client.Host, error)
CreateExposure(ctx context.Context, vmID string, guestPort, hostPort int64, protocol string) (client.Exposure, error)
ListExposures(ctx context.Context, vmID string) ([]client.Exposure, error)
DeleteExposure(ctx context.Context, id string) error
RegisterUserCA(ctx context.Context, caLine, label string) error
ListUserCAs(ctx context.Context, tenant string) ([]client.UserCA, error)
Delegation(ctx context.Context) (client.Delegation, error)
}
// Exec is everything the tools do on a VM once something has reached it. It is
// exported because the transport supplies it: the control plane builds a Runner
// over the sync tunnel.
type Exec interface {
Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error)
WriteFile(ctx context.Context, vmName, path string, data []byte, mode fs.FileMode) error
ReadFile(ctx context.Context, vmName, path string) ([]byte, bool, error)
// ConnectName maps a bare VM name to its <tenant>.<name> gate connect name,
// so the ssh_command hint dials the form the gate actually accepts.
ConnectName(ctx context.Context, vmName string) (string, error)
}
// API is the shared eitri API client plus the one piece of MCP placement
// policy the raw client doesn't carry: FirstEligibleHost.
type API struct {
*client.Client
}
// RegisterUserCA registers a CA public key with the caller's own tenant. The
// label rides a COPY of the client because it is a per-call choice and the
// client is shared with every other tool in this request; the request itself
// still goes through that same client, so there is one path to the API and one
// place authorization is decided.
func (a API) RegisterUserCA(ctx context.Context, caLine, label string) error {
c := *a.Client
if label != "" {
c.UserCALabel = label
}
return c.UploadUserCA(ctx, "", caLine)
}
// FirstEligibleHost returns the first host a VM can be placed on — the default
// target when the caller doesn't name one. network, when non-empty, is the
// named host network the VM asked for, and narrows the field to hosts serving
// it.
func (a API) FirstEligibleHost(ctx context.Context, network string) (client.Host, error) {
hosts, err := a.ListHosts(ctx)
if err != nil {
return client.Host{}, err
}
return eligibleHost(hosts, network)
}
// placeable reports whether a VM can be put on this host at all: it is online
// AND runs an agent that certifies its guests' host keys. A VM created anywhere
// else is unreachable through the gate, and the control plane refuses the
// create, so such a host is no more a candidate than one that is down.
//
// It is one predicate because two callers must agree: the rule that picks a
// host and the message that lists the alternatives. A refusal naming a host
// placement would not have chosen sends the caller after the wrong remedy.
func placeable(h client.Host) bool {
return h.Online && release.CertifiesGuestHostKeys(h.AgentVersion)
}
// eligibleHost is the placement rule itself, over a fleet already read. It is a
// function of the listing alone so the tools' fake obeys the same rule the real
// client does, rather than a second, kinder one.
//
// A named network narrows the placeable hosts further — one that does not
// advertise it would refuse the create too. Ordering is server-defined; callers
// must not assume stability across calls.
//
// Each way of ruling every host out has its own refusal, because each has its
// own remedy. "no online hosts" would send a caller whose fleet is up and one
// upgrade away from usable looking for dead hardware; and it would tell a
// caller who asked for a network nothing about which hosts carry one.
func eligibleHost(hosts []client.Host, network string) (client.Host, error) {
precsr, unnetworked := 0, 0
for _, h := range hosts {
if !placeable(h) {
// Of the hosts that cannot take a VM, the ones that are UP are the
// ones an upgrade would fix; a host that is down needs nothing said
// about its agent.
if h.Online {
precsr++
}
continue
}
if network != "" && !slices.Contains(h.HostNetworks, network) {
unnetworked++
continue
}
return h, nil
}
// The network refusal comes first when both apply: it answers the question
// the caller actually asked, and it names the fleet's networks, which is the
// one place an MCP caller can learn them without provoking a 409.
if unnetworked > 0 {
return client.Host{}, fmt.Errorf("no eligible host advertises network %q (online hosts advertise: %s); "+
"name a host that does, restart an agent with --host-network %s=<bridge>, or omit network for the NAT underlay",
network, describeHostNetworks(hosts), network)
}
if precsr > 0 {
return client.Host{}, fmt.Errorf("no eligible hosts: %d online host(s) run agents that predate certified host keys (%s), "+
"so a VM created on them could not be verified; upgrade an agent (the console's upgrade button, or "+
"POST /api/v1/hosts/{id}/upgrade-agent) and try again", precsr, release.FirstCertifiedHostKeys)
}
return client.Host{}, errors.New("no online hosts")
}
// The real client and runner must satisfy the seams unchanged.
var (
_ api = API{}
_ Exec = (*Runner)(nil)
)
// Tools implements the ten eitri VM tools over the API and SSH seams.
type Tools struct {
API api
Runner Exec
Gate string // gate address, for the ssh command hint only
VMUser string
PollEvery time.Duration // create-wait poll interval (default 2s)
WaitTimeout time.Duration // create-wait ceiling (default 10m)
}
func (t *Tools) pollEvery() time.Duration {
if t.PollEvery > 0 {
return t.PollEvery
}
return 2 * time.Second
}
func (t *Tools) waitTimeout() time.Duration {
if t.WaitTimeout > 0 {
return t.WaitTimeout
}
return 10 * time.Minute
}
// ── vm_create ────────────────────────────────────────────────────────────────
type VMCreateIn struct {
Name string `json:"name,omitempty" jsonschema:"VM name (RFC-1123 label); default claude-<hex>"`
Host string `json:"host,omitempty" jsonschema:"host name to place on; default first eligible host"`
VCPUs int64 `json:"vcpus,omitempty" jsonschema:"default 2"`
MemMB int64 `json:"mem_mb,omitempty" jsonschema:"default 2048"`
DiskGB int64 `json:"disk_gb,omitempty" jsonschema:"default 10"`
CloudInit string `json:"cloud_init,omitempty" jsonschema:"optional user cloud-init, taken verbatim; eitri's own write_files and runcmd are prepended to yours, not replaced by them, and every scalar you set wins"`
Network string `json:"network,omitempty" jsonschema:"named host network to give the guest a SECOND NIC on, addressed by that network's own DHCP; omit for the private NAT underlay every guest has anyway. A host serves only the names it advertises: naming another refuses the create and names the ones it does serve"`
Wait *bool `json:"wait,omitempty" jsonschema:"wait for ready+cloud-init (default true)"`
}
type VMCreateOut struct {
ID string `json:"id"`
Name string `json:"name"`
// IP is the guest's address on its host's private fabric — the one the ssh
// command reaches it at, and never routable off that host.
IP string `json:"ip,omitempty"`
// Network is the named host network the guest was created on, echoed back
// because reaching this field at all means admission accepted the name.
Network string `json:"network,omitempty"`
// NetworkIP is the address that network's own DHCP granted the second NIC —
// what the rest of that network knows this guest by. Empty when no network
// was asked for, and empty at ready when the site's DHCP has not answered
// yet: readiness is boot, and the lease arrives when it arrives. Read it
// back with vm_info.
NetworkIP string `json:"network_ip,omitempty"`
SSHCommand string `json:"ssh_command,omitempty"`
// CloudInit warns about a non-clean-but-usable boot: set to a degraded
// message when cloud-init finished with recoverable errors (exit 2), omitted
// on a clean boot. The VM is ready either way — this never marks a failure.
CloudInit string `json:"cloud_init,omitempty"`
}
func (t *Tools) VMCreate(ctx context.Context, in VMCreateIn) (VMCreateOut, error) {
req := client.CreateVMRequest{
Name: in.Name,
CloudInit: in.CloudInit,
VCPUs: in.VCPUs,
MemMB: in.MemMB,
DiskGB: in.DiskGB,
Network: in.Network,
}
if req.Name == "" {
req.Name = "claude-" + random.Hex(3)
}
// Sizes (vcpus, mem_mb, disk_gb) are deliberately left at zero when omitted:
// the control plane owns those defaults (types.Default*), so a zero here
// inherits the one named value rather than pinning a second, drift-prone set
// on this side.
//
// A caller who named a host gets that host, network or not: the control
// plane owns whether that pairing is legal and refuses it in one message
// naming what the host does advertise. Re-deciding it here would put a
// second, drifting copy of the rule in front of the first. Only the DEFAULT
// placement — where nothing has been named and this side is choosing —
// takes the network into account, because choosing a host that cannot serve
// it would manufacture that refusal out of a free choice.
if in.Host != "" {
hostID, err := t.resolveHost(ctx, in.Host)
if err != nil {
return VMCreateOut{}, err
}
req.HostID = hostID
} else {
h, err := t.API.FirstEligibleHost(ctx, in.Network)
if err != nil {
return VMCreateOut{}, err
}
req.HostID = h.ID
}
created, err := t.API.CreateVM(ctx, req)
if err != nil {
return VMCreateOut{}, fmt.Errorf("create vm: %w", err)
}
// The network is echoed from the accepted request rather than waited for:
// it is frozen at create, and getting this far means admission took it.
out := VMCreateOut{ID: created.ID, Name: created.Name, Network: req.Network}
if in.Wait != nil && !*in.Wait {
return out, nil
}
// One shared deadline bounds the whole wait (ready + cloud-init) by
// WaitTimeout, rather than letting each phase burn a full budget.
deadline := time.Now().Add(t.waitTimeout())
ready, err := t.waitReady(ctx, created.ID, created.Name, deadline)
if err != nil {
// Spec: report state, never auto-destroy — the VM may just be slow.
return out, err
}
ip := ready.AssignedIP
out.IP = ip
// Once there is a server record, it outranks the echo above: the echo is
// what was ASKED for, and would quietly lie the day the server normalizes a
// stored name. The echo still stands alone on the no-wait path, where no
// record has been read.
if ready.Network != "" {
out.Network = ready.Network
}
// Whatever the named network has granted BY NOW, which may be nothing: the
// wait above never held for it.
out.NetworkIP = ready.NetworkIP
out.SSHCommand = t.sshCommand(ctx, created.Name)
// "ready" means cloud-hypervisor is up and the IP is ALLOCATED — NOT that the
// guest has booted Linux, brought up its NIC, and started sshd. The first SSH
// dials can therefore hit "no route to host"/"connection refused" while the
// guest is still in firmware/early boot. So retry the cloud-init wait,
// tolerating connection-level failures, until the shared deadline. The loop is
// purely for the pre-sshd window: once SSH connects, "cloud-init status --wait"
// itself blocks until cloud-init finishes, settling packages/runcmd.
//
// Any Exec error is treated as "guest not SSH-reachable yet" — within
// vm_create the only expected transient is the guest booting, and a persistent
// non-connection error still terminates cleanly at the deadline with lastErr in
// the message. We deliberately do NOT classify error strings.
//
// Error messages name the VM id AND name so the model can still find and
// destroy the degraded VM even if the MCP wrapper drops the structured
// result when err != nil.
var lastErr error
for {
// Host identity is now verified against the eitri CA (the VM presents a
// CA-signed host cert for its name), so there is no per-IP known_hosts pin
// to evict between retries — the guest regenerating its host key during
// cloud-init is transparent as long as the new key is a CA-signed cert.
// Give the command the time left in the shared budget (floored so a
// nearly-exhausted budget still gets a real chance).
remaining := max(time.Until(deadline), 30*time.Second)
res, execErr := t.Runner.Exec(ctx, created.Name, "cloud-init status --wait", remaining)
if execErr == nil {
switch res.ExitCode {
case 0:
return out, nil
case 2:
// cloud-init exit 2 is "done, with recoverable errors": the guest
// finished booting and is fully usable. Report the degradation as a
// warning on a SUCCESSFUL create — erroring here invites a retry that
// leaks the (working) VM.
out.CloudInit = "degraded — cloud-init reported recoverable errors"
return out, nil
default:
return out, fmt.Errorf("vm %s (%s) ready at %s but cloud-init exited %d: %s", created.ID, created.Name, ip, res.ExitCode, res.Stderr)
}
}
lastErr = execErr
reportProgress(ctx, fmt.Sprintf("vm %s is up at %s; waiting for cloud-init to finish", created.Name, ip))
// Stop once we are past the deadline, or the next sleep would carry us
// past it — no point sleeping only to give up.
if !time.Now().Add(t.pollEvery()).Before(deadline) {
return out, fmt.Errorf("vm %s (%s) ready at %s but never became SSH-reachable within %s: %w", created.ID, created.Name, ip, t.waitTimeout(), lastErr)
}
select {
case <-ctx.Done():
return out, ctx.Err()
case <-time.After(t.pollEvery()):
}
}
}
// waitReady polls until the VM reaches "ready" with an IP, or the shared
// deadline expires, and hands back the listing that satisfied it. Transient
// ListVMs failures (control-plane restart/blip/5xx) do NOT abort the wait —
// they are stashed and polling continues, per the tool's "do not assume
// failure" contract. Only real cancellation (ctx.Done) aborts immediately.
//
// The IP waited for is assigned_ip, the host-fabric address, and only that one.
// A guest on a named host network gets its second address from that network's
// own DHCP server, which is not eitri's and owes eitri no schedule; holding
// readiness open for it would make a working guest look like a failed create
// because a site's DHCP was slow. So network_ip travels back as whatever it is
// at ready, empty included.
func (t *Tools) waitReady(ctx context.Context, id, name string, deadline time.Time) (client.VM, error) {
last, detail := "", ""
sawListing := false
var lastErr error
for time.Now().Before(deadline) {
vms, err := t.API.ListVMs(ctx)
if err != nil {
lastErr = err
} else {
sawListing = true
for _, vm := range vms {
if vm.ID != id {
continue
}
last, detail = vm.Lifecycle, vm.StatusDetail
if vm.Lifecycle == "ready" && vm.AssignedIP != "" {
return vm, nil
}
}
}
reportProgress(ctx, fmt.Sprintf("creating vm %s: %s", name, lifecycleOrUnknown(last, detail)))
select {
case <-ctx.Done():
return client.VM{}, ctx.Err()
case <-time.After(t.pollEvery()):
}
}
if sawListing {
return client.VM{}, fmt.Errorf("vm %s (%s) not ready after %s (last lifecycle %q); it may still come up — check vm_info, do not assume failure", id, name, t.waitTimeout(), last)
}
return client.VM{}, fmt.Errorf("vm %s (%s) not ready after %s (control-plane never listed successfully; last error: %v); it may still come up — check vm_info, do not assume failure", id, name, t.waitTimeout(), lastErr)
}
// lifecycleOrUnknown words a lifecycle for a progress message, covering the
// first poll or two where the control plane has not listed the VM yet.
//
// The host's own account of what it is doing rides along when there is one.
// "creating" is a single word for minutes of image, disk and boot, and a caller
// watching this stream has no other way to tell a download in progress from a
// create that is going nowhere.
func lifecycleOrUnknown(lifecycle, detail string) string {
if lifecycle == "" {
return "not listed yet"
}
if detail != "" {
return lifecycle + ", " + detail
}
return lifecycle
}
// resolveHost turns a caller-named host into the id to place on: an exact match
// on host id or host name, anywhere in the fleet. An offline match is refused
// rather than accepted — nothing would converge the create until that host came
// back, so the VM would simply sit there. Errors name the hosts that DO exist
// and whether each is online, so the model can correct itself without a second
// call.
func (t *Tools) resolveHost(ctx context.Context, name string) (string, error) {
hosts, err := t.API.ListHosts(ctx)
if err != nil {
return "", err
}
for _, h := range hosts {
if h.ID != name && h.Name != name {
continue
}
if !h.Online {
return "", fmt.Errorf("host %q is offline (fleet: %s)", name, describeHosts(hosts))
}
return h.ID, nil
}
return "", fmt.Errorf("no host with id or name %q (fleet: %s)", name, describeHosts(hosts))
}
// describeHosts renders the fleet as "name (online)" entries for an error.
func describeHosts(hosts []client.Host) string {
if len(hosts) == 0 {
return "none"
}
parts := make([]string, 0, len(hosts))
for _, h := range hosts {
state := "offline"
if h.Online {
state = "online"
}
parts = append(parts, fmt.Sprintf("%s (%s)", h.Name, state))
}
return strings.Join(parts, ", ")
}
// describeHostNetworks renders what each host a VM could actually be placed on
// advertises, as "onyx: lan, dmz; mewtwo: none".
//
// It lists exactly the hosts eligibleHost would consider, by asking the same
// predicate rather than by keeping a matching copy of it. Both exclusions earn
// their place: an offline host would be listed as serving nothing, but
// advertised networks are registry state emptied when a host stops reporting,
// so "none" for a machine that is down is a claim nobody asked it; and a host
// whose agent predates certified host keys would be recommended here only to
// refuse the create for an unrelated reason, sending the caller to configure a
// bridge when the remedy is an upgrade.
func describeHostNetworks(hosts []client.Host) string {
parts := make([]string, 0, len(hosts))
for _, h := range hosts {
if !placeable(h) {
continue
}
names := "none"
if len(h.HostNetworks) > 0 {
names = strings.Join(h.HostNetworks, ", ")
}
parts = append(parts, fmt.Sprintf("%s: %s", h.Name, names))
}
if len(parts) == 0 {
// Defensive: eligibleHost only asks after a host has passed both checks,
// so there is always at least one entry on that path.
return "no host is eligible to place on"
}
return strings.Join(parts, "; ")
}
func (t *Tools) sshCommand(ctx context.Context, name string) string {
if t.Gate == "" {
// No gate: reach the VM directly by name/IP (a different deployment
// shape). Unchanged.
return fmt.Sprintf("ssh %s@%s", t.VMUser, name)
}
// The gate rejects a bare VM name — VMs are dialed by their <tenant>.<name>
// connect name (also the VM's host-cert principal), resolved via the same
// gate auth the Runner uses. If it can't be resolved (e.g. a mint failure)
// omit the hint rather than emit the bare form the gate would reject.
target, err := t.Runner.ConnectName(ctx, name)
if err != nil {
return ""
}
return fmt.Sprintf("ssh -J %s %s@%s", t.Gate, t.VMUser, target)
}
// ── ca_upload ────────────────────────────────────────────────────────────────
type CAUploadIn struct {
PublicKey string `json:"public_key" jsonschema:"the CA's PUBLIC key as one authorized_keys line, e.g. 'ssh-ed25519 AAAA... alex@laptop'"`
Label string `json:"label,omitempty" jsonschema:"optional label for this CA in the console's list"`
}
type CAUploadOut struct {
Fingerprint string `json:"fingerprint"`
Label string `json:"label,omitempty"`
Note string `json:"note"`
}
// CAUpload registers a tenant's own SSH user CA. Only the public half travels —
// there is nowhere in this call to put a private key, which is the point.
//
// The line is parsed here before it is sent so a malformed key is a tool error
// naming the problem, rather than a 400 the model has to interpret. The
// fingerprint returned is computed from the same bytes, so the caller can see
// which CA it just registered without a second round trip.
func (t *Tools) CAUpload(ctx context.Context, in CAUploadIn) (CAUploadOut, error) {
line := strings.TrimSpace(in.PublicKey)
if line == "" {
return CAUploadOut{}, errors.New("public_key is required: the CA's public key as an authorized_keys line")
}
pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
if err != nil {
return CAUploadOut{}, fmt.Errorf("that is not an SSH public key line: %w", err)
}
if _, isCert := pub.(*ssh.Certificate); isCert {
return CAUploadOut{}, errors.New("that is a certificate, not a CA public key — upload the CA's own public key (the .pub beside your CA private key)")
}
if err := t.API.RegisterUserCA(ctx, line, in.Label); err != nil {
return CAUploadOut{}, fmt.Errorf("registering the CA with your tenant: %w", err)
}
return CAUploadOut{
Fingerprint: ssh.FingerprintSHA256(pub),
Label: in.Label,
Note: "A guest trusts the CA set it was created with, so VMs created BEFORE this upload will not " +
"accept certificates from this CA — create new ones. VMs created from now on will.",
}, nil
}
// ── tenant_info ──────────────────────────────────────────────────────────────
type TenantInfoIn struct{}
// TenantInfoCA is one registered CA, described rather than reproduced: a
// fingerprint identifies it, and the key itself is not what a caller is asking
// for here.
type TenantInfoCA struct {
Fingerprint string `json:"fingerprint"`
Label string `json:"label,omitempty"`
}
// TenantInfoDelegation is the live delegation, absent when there is none.
type TenantInfoDelegation struct {
KeyID string `json:"key_id,omitempty"`
Principals []string `json:"principals,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
type TenantInfoOut struct {
CAs []TenantInfoCA `json:"registered_cas"`
// Delegated says whether eitri can currently reach this tenant's VMs. It is
// a field of its own because "no delegation" is the answer a caller most
// often needs, and an absent object is easy to misread as an error.
Delegated bool `json:"delegated"`
Delegation *TenantInfoDelegation `json:"delegation,omitempty"`
Gate string `json:"gate,omitempty"`
}
// TenantInfo answers "what is set up for me right now" in one call: which CAs
// this tenant has registered, whether eitri holds a live delegation and until
// when, and the gate address. Without it a caller learns each of these by
// trying something and reading the failure, which is a slow way to be told
// that a step was skipped.
//
// No delegation is not an error — it is the common state, and it is reported
// as data so a caller can act on it rather than parse a refusal.
func (t *Tools) TenantInfo(ctx context.Context, _ TenantInfoIn) (TenantInfoOut, error) {
cas, err := t.API.ListUserCAs(ctx, "")
if err != nil {
return TenantInfoOut{}, fmt.Errorf("reading this tenant's registered CAs: %w", err)
}
out := TenantInfoOut{CAs: make([]TenantInfoCA, 0, len(cas)), Gate: t.Gate}
for _, ca := range cas {
out.CAs = append(out.CAs, TenantInfoCA{Fingerprint: ca.Fingerprint, Label: ca.Label})
}
// A plane with no delegation, or no gate at all, answers this with a
// non-2xx. Neither is a failure of the question being asked.
if d, derr := t.API.Delegation(ctx); derr == nil {
out.Delegated = true
out.Delegation = &TenantInfoDelegation{KeyID: d.KeyID, Principals: d.Principals, ExpiresAt: d.ExpiresAt}
}
return out, nil
}
// ── vm_list / vm_info ────────────────────────────────────────────────────────
type VMListIn struct{}
type VMListOut struct {
VMs []client.VM `json:"vms"`
}
func (t *Tools) VMList(ctx context.Context, _ VMListIn) (VMListOut, error) {
vms, err := t.API.ListVMs(ctx)
if err != nil {
return VMListOut{}, err
}
return VMListOut{VMs: vms}, nil
}
type VMInfoIn struct {
VM string `json:"vm" jsonschema:"VM id or exact name"`
}
type VMInfoOut struct {
VM client.VM `json:"vm"`
SSHCommand string `json:"ssh_command,omitempty"`
}
func (t *Tools) VMInfo(ctx context.Context, in VMInfoIn) (VMInfoOut, error) {
vm, err := t.resolveVM(ctx, in.VM)
if err != nil {
return VMInfoOut{}, err
}
out := VMInfoOut{VM: vm}
if vm.Lifecycle == "ready" {
out.SSHCommand = t.sshCommand(ctx, vm.Name)
}
return out, nil
}
// ── vm_exec ──────────────────────────────────────────────────────────────────
type VMExecIn struct {
VM string `json:"vm" jsonschema:"VM id or exact name"`
Command string `json:"command" jsonschema:"shell command to run as the VM user"`
TimeoutS int `json:"timeout_s,omitempty" jsonschema:"default 120"`
}
type VMExecOut struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
Truncated bool `json:"truncated,omitempty"`
}
func (t *Tools) VMExec(ctx context.Context, in VMExecIn) (VMExecOut, error) {
vm, err := t.resolveVM(ctx, in.VM)
if err != nil {
return VMExecOut{}, err
}
if vm.Lifecycle != "ready" {
return VMExecOut{}, fmt.Errorf("vm %s is not ready (lifecycle %q)", vm.Name, vm.Lifecycle)
}
timeout := 120 * time.Second
if in.TimeoutS > 0 {
timeout = time.Duration(in.TimeoutS) * time.Second
}
res, err := t.Runner.Exec(ctx, vm.Name, in.Command, timeout)
if err != nil {
return VMExecOut{}, err
}
return VMExecOut(res), nil
}
// ── vm_write_file / vm_read_file ─────────────────────────────────────────────
type VMWriteFileIn struct {
VM string `json:"vm" jsonschema:"VM id or exact name"`
Path string `json:"path" jsonschema:"absolute path in the VM"`
Content string `json:"content" jsonschema:"file contents (UTF-8 text)"`
Mode string `json:"mode,omitempty" jsonschema:"octal file mode, default 0644"`
}
type VMWriteFileOut struct {
Path string `json:"path"`
Bytes int `json:"bytes"`
}
func (t *Tools) VMWriteFile(ctx context.Context, in VMWriteFileIn) (VMWriteFileOut, error) {
vm, err := t.resolveVM(ctx, in.VM)
if err != nil {
return VMWriteFileOut{}, err
}
if vm.Lifecycle != "ready" {
return VMWriteFileOut{}, fmt.Errorf("vm %s is not ready (lifecycle %q)", vm.Name, vm.Lifecycle)
}
mode := fs.FileMode(0o644)
if in.Mode != "" {
var m uint32
if _, err := fmt.Sscanf(in.Mode, "%o", &m); err != nil {
return VMWriteFileOut{}, fmt.Errorf("bad mode %q: %w", in.Mode, err)
}
mode = fs.FileMode(m)
}
if err := t.Runner.WriteFile(ctx, vm.Name, in.Path, []byte(in.Content), mode); err != nil {
return VMWriteFileOut{}, err
}
return VMWriteFileOut{Path: in.Path, Bytes: len(in.Content)}, nil
}
type VMReadFileIn struct {
VM string `json:"vm" jsonschema:"VM id or exact name"`
Path string `json:"path" jsonschema:"absolute path in the VM"`
}
type VMReadFileOut struct {
Content string `json:"content"`
Truncated bool `json:"truncated,omitempty"`
}
func (t *Tools) VMReadFile(ctx context.Context, in VMReadFileIn) (VMReadFileOut, error) {
vm, err := t.resolveVM(ctx, in.VM)
if err != nil {
return VMReadFileOut{}, err
}
if vm.Lifecycle != "ready" {
return VMReadFileOut{}, fmt.Errorf("vm %s is not ready (lifecycle %q)", vm.Name, vm.Lifecycle)
}
data, truncated, err := t.Runner.ReadFile(ctx, vm.Name, in.Path)
if err != nil {
return VMReadFileOut{}, err
}
return VMReadFileOut{Content: string(data), Truncated: truncated}, nil
}
// ── vm_expose / vm_exposures / vm_unexpose ───────────────────────────────────
// ExposureView is the MCP view of one published port: the id needed to talk
// about it, both ends of the pipe, the address to dial, and what the host says
// its listener is doing. Address is empty until the host has reported its
// uplink — a grant exists before there is anywhere to name.
type ExposureView struct {
ID string `json:"id"`
GuestPort int64 `json:"guest_port"`
HostPort int64 `json:"host_port"`
Protocol string `json:"protocol"`
Address string `json:"address,omitempty"`
State string `json:"state"`
Reason string `json:"reason,omitempty"`
}
// exposureView folds an API exposure into the MCP view, joining the host
// address and host port into one dialable string. The protocol travels beside
// it rather than inside it: an address is a host and a port, and which of the
// two protocols to send is the caller's next decision, not part of the name.
func exposureView(e client.Exposure) ExposureView {
v := ExposureView{
ID: e.ID, GuestPort: e.GuestPort, HostPort: e.HostPort, Protocol: e.Protocol,
State: e.State, Reason: e.Reason,
}
if e.HostAddr != "" {
v.Address = net.JoinHostPort(e.HostAddr, strconv.FormatInt(e.HostPort, 10))
}
return v
}
type VMExposeIn struct {
VM string `json:"vm" jsonschema:"VM id or exact name"`
GuestPort int64 `json:"guest_port" jsonschema:"port the service listens on inside the guest"`
HostPort int64 `json:"host_port,omitempty" jsonschema:"port to bind on the host; omit to allocate one from 30000-32767, a named port must be >= 1024"`
Protocol string `json:"protocol,omitempty" jsonschema:"tcp (the default) or udp; the same host port can carry one of each"`
}
type VMExposeOut struct {
Exposure ExposureView `json:"exposure"`
}
// VMExpose publishes a guest port on the VM's host. The VM's lifecycle is not
// checked: an exposure is a durable grant the host binds when it next
// converges, so publishing a port on a VM that is still booting is legitimate.
func (t *Tools) VMExpose(ctx context.Context, in VMExposeIn) (VMExposeOut, error) {
vm, err := t.resolveVM(ctx, in.VM)
if err != nil {
return VMExposeOut{}, err
}
e, err := t.API.CreateExposure(ctx, vm.ID, in.GuestPort, in.HostPort, in.Protocol)
if err != nil {
return VMExposeOut{}, fmt.Errorf("expose vm %s port %d: %w", vm.Name, in.GuestPort, err)
}
return VMExposeOut{Exposure: exposureView(e)}, nil
}
type VMExposuresIn struct {
VM string `json:"vm" jsonschema:"VM id or exact name"`
}
type VMExposuresOut struct {
Exposures []ExposureView `json:"exposures"`
}
func (t *Tools) VMExposures(ctx context.Context, in VMExposuresIn) (VMExposuresOut, error) {
vm, err := t.resolveVM(ctx, in.VM)
if err != nil {
return VMExposuresOut{}, err
}
exps, err := t.API.ListExposures(ctx, vm.ID)
if err != nil {
return VMExposuresOut{}, fmt.Errorf("list exposures for vm %s: %w", vm.Name, err)
}
out := VMExposuresOut{Exposures: make([]ExposureView, 0, len(exps))}
for _, e := range exps {
out.Exposures = append(out.Exposures, exposureView(e))
}
return out, nil
}
type VMUnexposeIn struct {
VM string `json:"vm" jsonschema:"VM id or exact name"`
GuestPort int64 `json:"guest_port" jsonschema:"the guest port to stop publishing"`
HostPort int64 `json:"host_port,omitempty" jsonschema:"host port, only needed when the same guest port is published more than once"`
Protocol string `json:"protocol,omitempty" jsonschema:"tcp or udp, only needed when the same guest port is published in both"`
}
type VMUnexposeOut struct {
ID string `json:"id"`
GuestPort int64 `json:"guest_port"`
HostPort int64 `json:"host_port"`
Protocol string `json:"protocol"`
}
// VMUnexpose revokes one of a VM's published ports. The caller names the guest
// port it published, not the exposure id it never saw, so the id is resolved
// from the VM's own list.
func (t *Tools) VMUnexpose(ctx context.Context, in VMUnexposeIn) (VMUnexposeOut, error) {
vm, err := t.resolveVM(ctx, in.VM)
if err != nil {
return VMUnexposeOut{}, err
}
exps, err := t.API.ListExposures(ctx, vm.ID)
if err != nil {
return VMUnexposeOut{}, fmt.Errorf("list exposures for vm %s: %w", vm.Name, err)
}
e, err := matchExposure(exps, vm.Name, in.GuestPort, in.HostPort, in.Protocol)
if err != nil {
return VMUnexposeOut{}, err
}
if err := t.API.DeleteExposure(ctx, e.ID); err != nil {
return VMUnexposeOut{}, fmt.Errorf("unexpose vm %s port %d: %w", vm.Name, in.GuestPort, err)
}
return VMUnexposeOut{ID: e.ID, GuestPort: e.GuestPort, HostPort: e.HostPort, Protocol: e.Protocol}, nil
}
// matchExposure picks the one exposure of vmName publishing guestPort. Nothing
// stops a guest port being published on two host ports, or in both protocols,
// so hostPort (0 = any) and protocol ("" = any) disambiguate; an ambiguous
// match refuses rather than guessing which socket to close. Errors name what IS
// published, so the model can correct itself without a second listing call.
func matchExposure(exps []client.Exposure, vmName string, guestPort, hostPort int64, protocol string) (client.Exposure, error) {
var matches []client.Exposure
for _, e := range exps {
if e.GuestPort == guestPort && (hostPort == 0 || e.HostPort == hostPort) &&
(protocol == "" || e.Protocol == protocol) {
matches = append(matches, e)
}
}
switch len(matches) {
case 1:
return matches[0], nil
case 0:
return client.Exposure{}, fmt.Errorf("vm %s publishes no guest port %d (published: %s)", vmName, guestPort, describeExposures(exps))
default:
return client.Exposure{}, fmt.Errorf("vm %s publishes guest port %d more than once (%s); name host_port or protocol to pick one", vmName, guestPort, describeExposures(matches))
}
}
// describeExposures renders exposures as "guest:host/protocol" for an error.
func describeExposures(exps []client.Exposure) string {
if len(exps) == 0 {
return "none"
}
parts := make([]string, 0, len(exps))
for _, e := range exps {
parts = append(parts, fmt.Sprintf("%d:%d/%s", e.GuestPort, e.HostPort, e.Protocol))
}
return strings.Join(parts, ", ")
}
// ── vm_destroy ───────────────────────────────────────────────────────────────
type VMDestroyIn struct {
VM string `json:"vm" jsonschema:"VM id or EXACT name; destruction is explicit-only"`
}
type VMDestroyOut struct {
ID string `json:"id"`
Name string `json:"name"`
}
func (t *Tools) VMDestroy(ctx context.Context, in VMDestroyIn) (VMDestroyOut, error) {
vm, err := t.resolveVM(ctx, in.VM)
if err != nil {
return VMDestroyOut{}, err
}
if err := t.API.DeleteVM(ctx, vm.ID); err != nil {
return VMDestroyOut{}, err
}
return VMDestroyOut{ID: vm.ID, Name: vm.Name}, nil
}
// resolveVM matches id or exact name against the live VM list.
func (t *Tools) resolveVM(ctx context.Context, idOrName string) (client.VM, error) {
if idOrName == "" {
return client.VM{}, fmt.Errorf("vm is required (id or exact name)")
}
vms, err := t.API.ListVMs(ctx)
if err != nil {
return client.VM{}, err
}
for _, vm := range vms {
if vm.ID == idOrName || vm.Name == idOrName {
return vm, nil
}
}
return client.VM{}, fmt.Errorf("no VM with id or name %q", idOrName)
}