mcp

package
v0.6.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 30, 2026 License: MIT Imports: 27 Imported by: 0

Documentation

Overview

Package mcp implements an MCP server that exposes pixels sandbox lifecycle, exec, and file I/O as tools for AI agents.

Concurrency design

This package uses three lock primitives plus two independent primitives. Acquisition order (top down; never reverse):

  1. SandboxLocks.For(name) — long-held; per-sandbox container ops, including the provisioning goroutine and clone-from-base path.

  2. Builder.mu — short-held; protects in-flight tracking + failure cache.

  3. State.mu — short-held; protects in-memory state. NEVER held across backend I/O.

    INDEPENDENT PRIMITIVES (do not interact with the above):

    - BuildLock (file flock) — only acquired inside Builder.DoBuild; serialises CLI vs daemon builds of the same base. - PIDFile — held for daemon lifetime.

    INVARIANTS:

    - sync.Mutex is non-reentrant. Never re-acquire a lock you already hold. - Reaper.Tick uses TryLock on SandboxLocks. It never blocks. - The provisioning goroutine acquires SandboxLocks.For(newName) ITSELF (does not inherit a held lock from the request goroutine), and re-checks state at the top to handle the destroy-during-create race window. - Builder.Build dedupes concurrent in-process callers via singleflight.Group; cross-process serialization is via BuildLock (flock) inside DoBuild. - Builder.DoBuild's lock is acquired INSIDE the singleflight call — so only one in-process goroutine ever holds the file lock at a time.

Index

Constants

View Source
const DefaultBasePrefix = "base-"

DefaultBasePrefix is the fallback when cfg.MCP.BasePrefix is empty. Backends unconditionally prepend "px-" via prefixed(); this prefix sits *inside* that namespace, so the on-disk name is "px-base-<name>".

View Source
const InitialCheckpointLabel = "initial"

InitialCheckpointLabel is the label of the checkpoint created by BuildBase after the setup script runs. Sandboxes spawned right after build clone from this. Subsequent user `pixels checkpoint create` calls produce timestamped labels; the daemon picks by CreatedAt, not label.

Variables

View Source
var DefaultsFS embed.FS

Functions

func BaseName

func BaseName(cfg *config.Config, name string) string

BaseName returns the container name for a base. The prefix is taken from cfg.MCP.BasePrefix, falling back to DefaultBasePrefix when empty (e.g. in tests that build a Config by hand).

func BuildBase

func BuildBase(ctx context.Context, be sandbox.Sandbox, cfg *config.Config, name string, baseCfg config.Base, opts BuildBaseOpts) error

BuildBase materialises a base container.

If baseCfg.From is set: clone from the parent base's latest checkpoint into <BaseName(cfg, name)>. Otherwise create from baseCfg.ParentImage. Run the setup script in the new container, stop it, then create the initial checkpoint labelled InitialCheckpointLabel.

opts.Progress fires for these phases (in order):

"Cloning from <parent>" or "Creating from <image>"
"Waiting for container to be ready..."
"Uploading setup script..."
"Running setup script..."
"Stopping container..."
"Creating initial checkpoint..."

func BuildChain

func BuildChain(ctx context.Context, cfg *config.Config, target string, exists func(container string) bool, build func(name string) error) error

BuildChain ensures the dependency chain for `target` is built. It walks the `from` chain bottom-up; for any link whose container does not exist (per `exists`), `build(name)` is invoked. Build failures short-circuit (children of a failed parent are not attempted).

`exists(container)` is called with the fully-prefixed container name (BaseName(cfg, n)). `build(name)` is called with the bare base name.

func LatestCheckpointFor

func LatestCheckpointFor(ctx context.Context, be sandbox.Sandbox, container string) (sandbox.Snapshot, bool, error)

LatestCheckpointFor returns the most-recent (by CreatedAt) checkpoint on the named container, or ok=false if none exist.

func NewLogger

func NewLogger(w io.Writer, verbose bool) *slog.Logger

NewLogger returns a slog.Logger that writes to w. When verbose is true the level is Debug; otherwise Info. Format is text (one line per record) — structured but human-readable for daemon stderr.

func NopLogger

func NopLogger() *slog.Logger

NopLogger returns a logger that discards everything. Use in tests where log output isn't being asserted on.

Types

type Ack

type Ack struct {
	OK bool `json:"ok"`
}

type BaseView

type BaseView struct {
	Name           string     `json:"name"`
	Description    string     `json:"description,omitempty"`
	ParentImage    string     `json:"parent_image,omitempty"`
	From           string     `json:"from,omitempty"`
	Status         string     `json:"status"` // "ready" | "missing" | "building" | "failed"
	Error          string     `json:"error,omitempty"`
	LastCheckpoint *time.Time `json:"last_checkpoint,omitempty"`
}

type BuildBaseOpts

type BuildBaseOpts struct {
	// Out receives setup-script stdout/stderr. Pass io.Discard or a buffer
	// to silence; nil is treated as io.Discard.
	Out io.Writer
	// Progress is called once before each phase begins. nil = no-op.
	// See BuildBase for the phase strings.
	Progress func(phase string)
}

BuildBaseOpts controls BuildBase output and progress reporting.

type BuildLock

type BuildLock struct {
	// contains filtered or unexported fields
}

BuildLock is a file-level exclusive lock for serialising base builds across the daemon and CLI processes.

func AcquireBuildLock

func AcquireBuildLock(dir, name string) (*BuildLock, error)

AcquireBuildLock takes an exclusive flock on <dir>/builds/<name>.lock. Blocks if another process holds the lock for the same name.

func (*BuildLock) Release

func (l *BuildLock) Release()

Release drops the file lock. Safe to call multiple times.

type Builder

type Builder struct {
	// DoBuild performs the actual build. Set by the caller. Must be safe
	// to call from a goroutine.
	DoBuild func(ctx context.Context, name string) error

	// FailureTTL is how long a failed build is cached before retrying.
	// Zero means failures aren't cached.
	FailureTTL time.Duration
	// contains filtered or unexported fields
}

Builder coordinates concurrent in-process builds of named bases. It dedupes simultaneous callers (only one DoBuild runs per name) and caches recent failures so repeated callers don't burn cycles re-failing.

Cross-process serialisation against the CLI is provided by BuildLock, which DoBuild itself acquires inside its body.

func (*Builder) Build

func (b *Builder) Build(ctx context.Context, name string) error

Build is the deduplicated entrypoint. Concurrent callers for the same name share a single DoBuild invocation; the result is delivered to all.

func (*Builder) Status

func (b *Builder) Status(name string) (status string, err error)

Status returns the current state for name:

"building" — a DoBuild is in flight
"failed"   — a recent build failed and is still cached; err is non-nil
""         — neither in flight nor cached; caller checks snapshot existence
             to distinguish "ready" from "missing"

type CreateSandboxIn

type CreateSandboxIn struct {
	Label string `json:"label,omitempty"`
	Image string `json:"image,omitempty"`
	Base  string `json:"base,omitempty"`
}

type CreateSandboxOut

type CreateSandboxOut struct {
	Name   string `json:"name"`
	IP     string `json:"ip"`
	Status string `json:"status"`
}

type DeleteFileIn

type DeleteFileIn struct {
	Name string `json:"name"`
	Path string `json:"path"`
}

type EditFileIn

type EditFileIn struct {
	Name       string `json:"name"`
	Path       string `json:"path"`
	OldString  string `json:"old_string"`
	NewString  string `json:"new_string"`
	ReplaceAll bool   `json:"replace_all,omitempty"`
}

type EditFileOut

type EditFileOut struct {
	OK           bool `json:"ok"`
	Replacements int  `json:"replacements"`
}

type EmptyIn

type EmptyIn struct{}

EmptyIn is a placeholder input type for tools that take no arguments.

type ExecIn

type ExecIn struct {
	Name       string            `json:"name"`
	Command    []string          `json:"command"`
	Cwd        string            `json:"cwd,omitempty"`
	Env        map[string]string `json:"env,omitempty"`
	TimeoutSec int               `json:"timeout_sec,omitempty"`
}

type ExecOut

type ExecOut struct {
	ExitCode       int    `json:"exit_code"`
	Stdout         string `json:"stdout"`
	Stderr         string `json:"stderr"`
	TransportError string `json:"transport_error,omitempty"` // non-empty when the underlying SSH/exec channel failed
}

type LifecycleBackend

type LifecycleBackend interface {
	Stop(ctx context.Context, name string) error
	Delete(ctx context.Context, name string) error
}

LifecycleBackend is the subset of sandbox.Backend that the reaper needs.

type ListBasesOut

type ListBasesOut struct {
	Bases []BaseView `json:"bases"`
}

type ListFilesIn

type ListFilesIn struct {
	Name      string `json:"name"`
	Path      string `json:"path"`
	Recursive bool   `json:"recursive,omitempty"`
}

type ListFilesOut

type ListFilesOut struct {
	Entries []sandbox.FileEntry `json:"entries"`
}

type ListSandboxesOut

type ListSandboxesOut struct {
	Sandboxes []SandboxView `json:"sandboxes"`
}

type PIDFile

type PIDFile struct {
	// contains filtered or unexported fields
}

PIDFile is an acquired single-instance lock backed by a pidfile. The lock is held via flock(2); the kernel releases it automatically when the holder process exits, so there is no stale-PID state to detect.

func AcquirePIDFile

func AcquirePIDFile(path string) (*PIDFile, error)

AcquirePIDFile takes a non-blocking exclusive flock on path, then writes the holder's PID. If another live process holds the lock, the existing PID is included in the error when readable.

func (*PIDFile) Release

func (p *PIDFile) Release()

Release drops the flock. The pidfile is intentionally left on disk — the kernel flock is the source of truth for liveness, and removing the file could race with a successor process that has already taken the lock and written its own PID. A successor's TryLock + truncate-and-write naturally overwrites any stale content. Safe to call multiple times.

type ReadFileIn

type ReadFileIn struct {
	Name     string `json:"name"`
	Path     string `json:"path"`
	MaxBytes int64  `json:"max_bytes,omitempty"`
}

type ReadFileOut

type ReadFileOut struct {
	Content   string `json:"content"`
	Truncated bool   `json:"truncated"`
}

type Reaper

type Reaper struct {
	State            *State
	Backend          LifecycleBackend
	Locks            *SandboxLocks // shared with Tools; TryLock to skip busy sandboxes
	IdleStopAfter    time.Duration
	HardDestroyAfter time.Duration
	Log              *slog.Logger
	Now              func() time.Time // injectable clock for tests
}

Reaper enforces idle-stop and hard-destroy TTLs on tracked sandboxes.

func (*Reaper) Run

func (r *Reaper) Run(ctx context.Context, interval time.Duration)

Run starts a ticker that calls Tick until ctx is cancelled.

func (*Reaper) Tick

func (r *Reaper) Tick(ctx context.Context)

Tick performs one reaper pass. Safe to call repeatedly.

type Sandbox

type Sandbox struct {
	Name           string    `json:"name"`
	Label          string    `json:"label,omitempty"`
	Image          string    `json:"image"`
	Base           string    `json:"base,omitempty"` // name of the base, if cloned
	IP             string    `json:"ip,omitempty"`
	Status         string    `json:"status"`          // "provisioning" | "running" | "stopped" | "failed"
	Error          string    `json:"error,omitempty"` // populated when status=failed
	CreatedAt      time.Time `json:"created_at"`
	LastActivityAt time.Time `json:"last_activity_at"`
}

Sandbox is a single tracked MCP-managed sandbox.

type SandboxLocks

type SandboxLocks struct {
	// contains filtered or unexported fields
}

SandboxLocks provides one mutex per sandbox name. Tool handlers acquire the lock for the duration of any container-touching call so concurrent ops on a single sandbox do not race; the reaper uses TryLock to skip busy sandboxes.

Locks are created on first access and never pruned. Each entry is a few bytes; sandbox count is bounded by user activity. If memory growth becomes a concern (it has not, by orders of magnitude), add refcounted deletion on Destroy — out of scope for v1.

func (*SandboxLocks) Acquire

func (l *SandboxLocks) Acquire(name string) func()

Acquire locks the sandbox's mutex and returns its Unlock function, intended for the idiom `defer t.Locks.Acquire(name)()` in handlers that hold the lock for the entire call.

func (*SandboxLocks) For

func (l *SandboxLocks) For(name string) *sync.Mutex

For returns the mutex for the given sandbox name, creating it on first access. The caller is responsible for Lock/Unlock. Safe for concurrent use.

type SandboxRef

type SandboxRef struct {
	Name string `json:"name"`
}

type SandboxView

type SandboxView struct {
	Name           string    `json:"name"`
	Label          string    `json:"label,omitempty"`
	Status         string    `json:"status"`
	Error          string    `json:"error,omitempty"`
	IP             string    `json:"ip,omitempty"`
	Base           string    `json:"base,omitempty"`
	CreatedAt      time.Time `json:"created_at"`
	LastActivityAt time.Time `json:"last_activity_at"`
	IdleFor        string    `json:"idle_for"`
}

type ServerOpts

type ServerOpts struct {
	State          *State
	Backend        sandbox.Sandbox
	Prefix         string
	DefaultImage   string
	ExecTimeoutMax time.Duration
	Log            *slog.Logger
	Locks          *SandboxLocks   // shared with Reaper; constructed by caller
	DaemonCtx      context.Context // outlives any single request; provisioning goroutine inherits this
	Cfg            *config.Config
	Builder        *Builder
	BuildLockDir   string
}

ServerOpts bundles construction parameters.

type State

type State struct {
	// contains filtered or unexported fields
}

State is the in-memory + on-disk MCP state.

func LoadState

func LoadState(path string) (*State, error)

LoadState reads state from path. Missing or corrupt files yield an empty state.

func (*State) Add

func (s *State) Add(sb Sandbox)

Add inserts or replaces a sandbox.

func (*State) BumpActivity

func (s *State) BumpActivity(name string, t time.Time)

BumpActivity advances last_activity_at for the given sandbox. No-op if missing.

func (*State) Get

func (s *State) Get(name string) (Sandbox, bool)

Get returns a sandbox by name and whether it was found.

func (*State) MarkFailed

func (s *State) MarkFailed(name string, err error)

MarkFailed transitions a sandbox to "failed" and records the error message.

func (*State) MarkRunning

func (s *State) MarkRunning(name string)

MarkRunning transitions a sandbox to "running" and clears any prior error.

func (*State) Remove

func (s *State) Remove(name string)

Remove deletes a sandbox by name. No-op if not present.

func (*State) Sandboxes

func (s *State) Sandboxes() []Sandbox

Sandboxes returns a copy of the current sandboxes. Order is undefined.

func (*State) Save

func (s *State) Save() error

Save persists state via renameio's maybe.WriteFile: atomic on Unix (a crash mid-save leaves either the previous or new contents, never zero-length) and best-effort on Windows. On-disk sandbox order is non-deterministic across saves (map iteration order).

func (*State) SetIP

func (s *State) SetIP(name, ip string)

SetIP updates a sandbox's IP address. No-op if missing.

func (*State) SetLogger

func (s *State) SetLogger(l *slog.Logger)

SetLogger assigns the logger after construction. Call once at startup.

func (*State) SetPathForTest

func (s *State) SetPathForTest(p string)

SetPathForTest sets the state file path for testing only.

func (*State) SetStatus

func (s *State) SetStatus(name, status string)

SetStatus updates a sandbox's status. No-op if missing.

type Tools

type Tools struct {
	State          *State
	Backend        sandbox.Sandbox
	Prefix         string
	DefaultImage   string
	ExecTimeoutMax time.Duration
	Log            *slog.Logger    // not nil; defaults to NopLogger if not set
	Locks          *SandboxLocks   // shared with Reaper; never nil after NewServer
	DaemonCtx      context.Context // outlives any single request; provisioning goroutine inherits this
	Cfg            *config.Config
	Builder        *Builder
	BuildLockDir   string
	// contains filtered or unexported fields
}

Tools is the dependency bundle every MCP handler closes over.

func NewServer

func NewServer(opts ServerOpts, endpointPath string) (http.Handler, *Tools)

NewServer wires the MCP tool surface and returns an HTTP handler ready to mount. The second return value (*Tools) is a test affordance — it gives tests access to the handler functions directly without an HTTP round-trip.

func (*Tools) CreateSandbox

func (t *Tools) CreateSandbox(ctx context.Context, in CreateSandboxIn) (CreateSandboxOut, error)

func (*Tools) DeleteFile

func (t *Tools) DeleteFile(ctx context.Context, in DeleteFileIn) (Ack, error)

func (*Tools) DestroySandbox

func (t *Tools) DestroySandbox(ctx context.Context, in SandboxRef) (Ack, error)

func (*Tools) EditFile

func (t *Tools) EditFile(ctx context.Context, in EditFileIn) (EditFileOut, error)

EditFile is read-modify-write composed in the MCP layer. It mirrors the shape of Claude's Edit tool: errors if old_string is missing or non-unique (unless replace_all is true).

func (*Tools) Exec

func (t *Tools) Exec(ctx context.Context, in ExecIn) (ExecOut, error)

func (*Tools) ListBases

func (t *Tools) ListBases(ctx context.Context, _ EmptyIn) (ListBasesOut, error)

func (*Tools) ListFiles

func (t *Tools) ListFiles(ctx context.Context, in ListFilesIn) (ListFilesOut, error)

func (*Tools) ListSandboxes

func (t *Tools) ListSandboxes(ctx context.Context, _ EmptyIn) (ListSandboxesOut, error)

func (*Tools) ReadFile

func (t *Tools) ReadFile(ctx context.Context, in ReadFileIn) (ReadFileOut, error)

func (*Tools) StartSandbox

func (t *Tools) StartSandbox(ctx context.Context, in SandboxRef) (CreateSandboxOut, error)

func (*Tools) StopSandbox

func (t *Tools) StopSandbox(ctx context.Context, in SandboxRef) (Ack, error)

func (*Tools) WaitProvisioning

func (t *Tools) WaitProvisioning()

WaitProvisioning blocks until all in-flight provisioning goroutines complete. Used by the daemon shutdown path so final State writes (MarkRunning / MarkFailed / SetIP) make it to disk before the process exits.

func (*Tools) WriteFile

func (t *Tools) WriteFile(ctx context.Context, in WriteFileIn) (WriteFileOut, error)

type WriteFileIn

type WriteFileIn struct {
	Name    string `json:"name"`
	Path    string `json:"path"`
	Content string `json:"content"`
	Mode    string `json:"mode,omitempty"` // octal string e.g. "0644"
}

type WriteFileOut

type WriteFileOut struct {
	OK           bool `json:"ok"`
	BytesWritten int  `json:"bytes_written"`
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL