a73x

internal/mcpserver/server.go

Ref:   Size: 8.8 KiB   History

// Package mcpserver implements eitri's MCP toolset: tools that let a model
// create, control (SSH exec/files), and destroy eitri VMs. The control plane
// serves it at /mcp (internal/server/mcphttp). It is an API CLIENT of the
// control plane — it speaks to it only through the shared API client
// (internal/server/api/client), never any other server internals, and the PAT
// it holds must never appear in tool results or errors.
package mcpserver

import (
	"context"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

// Options is what a server needs beyond the tools themselves.
type Options struct {
	Delegator   Delegator        // required (non-nil): every server exposes the delegate tools
	SchemaCache *mcp.SchemaCache // shared across per-request servers, so re-registering tools costs no reflection
}

// Delegator is the two-step exchange by which a caller lends eitri access:
// eitri offers a public key, the caller's own CA signs it, eitri gets the
// certificate back.
type Delegator interface {
	Begin(ctx context.Context) (BeginResult, error)
	Complete(ctx context.Context, certificate string) (DelegationResult, error)
}

// BeginResult is what the model shows its human. Note is worded for a model
// that would otherwise try to complete the exchange by itself.
type BeginResult struct {
	PublicKey    string `json:"public_key"`
	Principal    string `json:"principal"`
	Instructions string `json:"instructions"`
	Note         string `json:"note"`
}

// DelegationResult reports what eitri may now do, and until when.
type DelegationResult struct {
	ExpiresAt     string   `json:"expires_at"`
	CAFingerprint string   `json:"ca_fingerprint"`
	Principals    []string `json:"principals"`
	Note          string   `json:"note"`
}

// DelegateBeginIn takes no arguments: asking is the whole operation.
type DelegateBeginIn struct{}

// DelegateCompleteIn carries the signed certificate. A certificate is public
// material — see the tool description, which says so, because a model may
// otherwise refuse to paste something that looks like key material.
type DelegateCompleteIn struct {
	Certificate string `json:"certificate" jsonschema:"the contents of the *-cert.pub file your CA produced"`
}

// NewServer builds the MCP server for one identity. Every caller goes through
// here, so the tool list is the same one for everyone.
func NewServer(t *Tools, opts Options) *mcp.Server {
	s := mcp.NewServer(&mcp.Implementation{Name: "eitri", Version: "0.1.0"},
		&mcp.ServerOptions{SchemaCache: opts.SchemaCache})
	register(s, "vm_create",
		"Create an eitri VM. Waits for ready+cloud-init by default. Name a network to also put the guest on one "+
			"of its host's named networks, where that network's own DHCP addresses it: the result reports that "+
			"address as network_ip, which may still be EMPTY when the tool returns — readiness is the guest booting, "+
			"and a lease from the site's DHCP server arrives when it arrives. Read it back with vm_info rather "+
			"than treating an empty one as a failed create.",
		t.VMCreate)
	register(s, "vm_list", "List all VMs on the eitri fleet.", t.VMList)
	register(s, "vm_info", "Show one VM's state and how to reach it.", t.VMInfo)
	register(s, "vm_exec", "Run a shell command in a VM over SSH; returns stdout/stderr/exit code.", t.VMExec)
	register(s, "vm_write_file", "Write content to a file in a VM (parents created).", t.VMWriteFile)
	register(s, "vm_read_file", "Read a file from a VM (capped at 1 MiB).", t.VMReadFile)
	register(s, "vm_expose", "Publish a VM's guest port on its host, TCP or UDP, and return the address to dial. Omit host_port to allocate one from 30000-32767. WARNING: a published port has NO AUTHENTICATION in front of it — whoever can reach the host on that port reaches the service, and a UDP one answers whatever address a datagram claims to come from. Publish only what is meant to be reachable.", t.VMExpose)
	register(s, "vm_exposures", "List a VM's published ports, with the protocol, the address to dial and each socket's state. These ports are unauthenticated.", t.VMExposures)
	register(s, "vm_unexpose", "Stop publishing a VM's guest port; the host closes the socket.", t.VMUnexpose)
	register(s, "vm_destroy", "Destroy a VM by id or EXACT name. Explicit-only; never called automatically.", t.VMDestroy)
	register(s, "ca_upload",
		"Register your SSH user CA's PUBLIC key with your tenant, so your guests trust certificates it signs. "+
			"Only the public half is sent — eitri never holds a signing key. "+
			"IMPORTANT: a guest bakes its trusted CA set when it is created, so VMs that already exist will NOT "+
			"trust a CA uploaded now; create new VMs after uploading. Do this before vm_create, and before delegating.",
		t.CAUpload)
	register(s, "tenant_info",
		"Show how this tenant is set up: which SSH CAs are registered (fingerprint and label), whether eitri "+
			"currently holds a delegation and when it expires, and the gate address. Read-only. Call it first "+
			"when an SSH operation fails, instead of guessing which step was skipped.",
		t.TenantInfo)
	dg := opts.Delegator
	register(s, "delegate_begin",
		"Ask for the public key your CA is to sign, so eitri can reach your VMs. eitri holds no signing key and "+
			"cannot sign this itself — that is the point. Show the human the public key and the command, and wait "+
			"for them to hand back the certificate; do not try to produce it yourself. The key stays the same "+
			"until the control plane restarts, and a begin nobody signs is abandoned after an hour — a "+
			"delegation is held in memory, so call this tool again for the current key rather than reusing an "+
			"old certificate.",
		func(ctx context.Context, _ DelegateBeginIn) (BeginResult, error) { return dg.Begin(ctx) })
	register(s, "delegate_complete",
		"Hand back the certificate your CA signed. Certificates are public material, so passing one as an "+
			"argument is safe — it is not a key and grants nothing without the key eitri holds in memory. "+
			"Afterwards eitri can reach your VMs until the certificate expires. Because it chains to a CA your "+
			"tenant already registered, VMs created before this call accept it too.",
		func(ctx context.Context, in DelegateCompleteIn) (DelegationResult, error) {
			return dg.Complete(ctx, in.Certificate)
		})
	return s
}

// progressKey carries the per-request progress reporter.
type progressKey struct{}

// withProgress returns a context whose long waits report to report. A transport
// that cannot deliver notifications simply does not install one.
func withProgress(ctx context.Context, report func(message string)) context.Context {
	return context.WithValue(ctx, progressKey{}, report)
}

// reportProgress tells the caller a long-running tool is still working. It is a
// no-op when nothing is listening, which is every case but a remote tool call
// whose client asked for progress.
//
// It is not only courtesy: vm_create blocks for up to ten minutes and emits
// nothing until it finishes, and a proxy in front of the control plane will cut
// an origin connection that stays silent for a hundred seconds.
func reportProgress(ctx context.Context, message string) {
	if report, ok := ctx.Value(progressKey{}).(func(string)); ok && report != nil {
		report(message)
	}
}

// register adapts a Tools method to the SDK. This is the ONLY place that
// touches SDK generics; if the SDK's handler signature changes, change it here.
//
// Note on the hand-off contract: the SDK drops the Out value when the handler
// returns a non-nil error — StructuredContent is left unset and only err.Error()
// reaches the model (as IsError text content). VMCreate's degraded-path errors
// are self-sufficient (they name the VM id+name), so the model can still find
// and destroy the VM from the error text.
func register[In, Out any](s *mcp.Server, name, desc string, fn func(context.Context, In) (Out, error)) {
	mcp.AddTool(s, &mcp.Tool{Name: name, Description: desc},
		func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) {
			out, err := fn(withProgress(ctx, progressReporter(ctx, req)), in)
			return nil, out, err
		})
}

// progressReporter builds the notifier for one tool call, or nil when the
// client did not ask for progress. The MCP contract is that progress is
// reported only against a token the client supplied, so a client that sent none
// gets none.
func progressReporter(ctx context.Context, req *mcp.CallToolRequest) func(string) {
	if req == nil || req.Session == nil || req.Params == nil {
		return nil
	}
	token := req.Params.GetProgressToken()
	if token == nil {
		return nil
	}
	session := req.Session
	step := 0.0
	return func(message string) {
		step++
		// Best-effort: a client that has gone away must not fail the operation
		// it asked for.
		_ = session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{
			ProgressToken: token, Message: message, Progress: step,
		})
	}
}