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):
SandboxLocks.For(name) — long-held; per-sandbox container ops, including the provisioning goroutine and clone-from-base path.
Builder.mu — short-held; protects in-flight tracking + failure cache.
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
- Variables
- func BaseName(cfg *config.Config, name string) string
- func BuildBase(ctx context.Context, be sandbox.Sandbox, cfg *config.Config, name string, ...) error
- func BuildChain(ctx context.Context, cfg *config.Config, target string, ...) error
- func LatestCheckpointFor(ctx context.Context, be sandbox.Sandbox, container string) (sandbox.Snapshot, bool, error)
- func NewLogger(w io.Writer, verbose bool) *slog.Logger
- func NopLogger() *slog.Logger
- type Ack
- type BaseView
- type BuildBaseOpts
- type BuildLock
- type Builder
- type CreateSandboxIn
- type CreateSandboxOut
- type DeleteFileIn
- type EditFileIn
- type EditFileOut
- type EmptyIn
- type ExecIn
- type ExecOut
- type LifecycleBackend
- type ListBasesOut
- type ListFilesIn
- type ListFilesOut
- type ListSandboxesOut
- type PIDFile
- type ReadFileIn
- type ReadFileOut
- type Reaper
- type Sandbox
- type SandboxLocks
- type SandboxRef
- type SandboxView
- type ServerOpts
- type State
- func (s *State) Add(sb Sandbox)
- func (s *State) BumpActivity(name string, t time.Time)
- func (s *State) Get(name string) (Sandbox, bool)
- func (s *State) MarkFailed(name string, err error)
- func (s *State) MarkRunning(name string)
- func (s *State) Remove(name string)
- func (s *State) Sandboxes() []Sandbox
- func (s *State) Save() error
- func (s *State) SetIP(name, ip string)
- func (s *State) SetLogger(l *slog.Logger)
- func (s *State) SetPathForTest(p string)
- func (s *State) SetStatus(name, status string)
- type Tools
- func (t *Tools) CreateSandbox(ctx context.Context, in CreateSandboxIn) (CreateSandboxOut, error)
- func (t *Tools) DeleteFile(ctx context.Context, in DeleteFileIn) (Ack, error)
- func (t *Tools) DestroySandbox(ctx context.Context, in SandboxRef) (Ack, error)
- func (t *Tools) EditFile(ctx context.Context, in EditFileIn) (EditFileOut, error)
- func (t *Tools) Exec(ctx context.Context, in ExecIn) (ExecOut, error)
- func (t *Tools) ListBases(ctx context.Context, _ EmptyIn) (ListBasesOut, error)
- func (t *Tools) ListFiles(ctx context.Context, in ListFilesIn) (ListFilesOut, error)
- func (t *Tools) ListSandboxes(ctx context.Context, _ EmptyIn) (ListSandboxesOut, error)
- func (t *Tools) ReadFile(ctx context.Context, in ReadFileIn) (ReadFileOut, error)
- func (t *Tools) StartSandbox(ctx context.Context, in SandboxRef) (CreateSandboxOut, error)
- func (t *Tools) StopSandbox(ctx context.Context, in SandboxRef) (Ack, error)
- func (t *Tools) WaitProvisioning()
- func (t *Tools) WriteFile(ctx context.Context, in WriteFileIn) (WriteFileOut, error)
- type WriteFileIn
- type WriteFileOut
Constants ¶
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>".
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 ¶
var DefaultsFS embed.FS
Functions ¶
func BaseName ¶
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.
Types ¶
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 ¶
AcquireBuildLock takes an exclusive flock on <dir>/builds/<name>.lock. Blocks if another process holds the lock for the same name.
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 ¶
Build is the deduplicated entrypoint. Concurrent callers for the same name share a single DoBuild invocation; the result is delivered to all.
type CreateSandboxIn ¶
type CreateSandboxOut ¶
type DeleteFileIn ¶
type EditFileIn ¶
type EditFileOut ¶
type EmptyIn ¶
type EmptyIn struct{}
EmptyIn is a placeholder input type for tools that take no arguments.
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 ListFilesOut ¶
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 ¶
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 ReadFileOut ¶
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.
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.
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 (*State) BumpActivity ¶
BumpActivity advances last_activity_at for the given sandbox. No-op if missing.
func (*State) MarkFailed ¶
MarkFailed transitions a sandbox to "failed" and records the error message.
func (*State) MarkRunning ¶
MarkRunning transitions a sandbox to "running" and clears any prior error.
func (*State) Save ¶
Save persists state atomically via renameio: a crash mid-save leaves either the previous contents or the new contents, never zero-length. On-disk sandbox order is non-deterministic across saves (map iteration order).
func (*State) SetPathForTest ¶
SetPathForTest sets the state file path for testing only.
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 (*Tools) DestroySandbox ¶
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) ListFiles ¶
func (t *Tools) ListFiles(ctx context.Context, in ListFilesIn) (ListFilesOut, error)
func (*Tools) ListSandboxes ¶
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 (*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)