Skip to content

Commit 3b32043

Browse files
committed
Merge pull request 'MCP Server' (#14) from feature/mcp-server into main
Reviewed-on: https://forgejo.tail9a847c.ts.net/sh/pixels/pulls/14
2 parents bbe4ce0 + f72306b commit 3b32043

55 files changed

Lines changed: 6260 additions & 70 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,145 @@ Create `~/.config/pixels/config.toml`:
328328
| `PIXELS_PROVISION_DEVTOOLS` | `provision.devtools` |
329329
| `PIXELS_NETWORK_EGRESS` | `network.egress` |
330330

331+
## Using `pixels` as an MCP code-sandbox server
332+
333+
`pixels mcp` runs a streamable-HTTP MCP server that exposes container
334+
lifecycle, exec, and file CRUD as MCP tools. Run it once on your
335+
machine, then point any number of MCP clients at it.
336+
337+
### Start the daemon
338+
339+
pixels mcp
340+
341+
By default it binds to `http://127.0.0.1:8765/mcp` and refuses to start
342+
if another instance is already running (PID file at
343+
`~/.cache/pixels/mcp.pid`).
344+
345+
### Configure your client
346+
347+
Claude Code MCP entry:
348+
349+
{
350+
"mcpServers": {
351+
"pixels": {
352+
"type": "http",
353+
"url": "http://127.0.0.1:8765/mcp"
354+
}
355+
}
356+
}
357+
358+
### Tools
359+
360+
| Tool | What it does |
361+
|---|---|
362+
| `create_sandbox` | Spin up a new ephemeral container (`base` for fast clone, `image` for raw) |
363+
| `list_sandboxes` | List tracked sandboxes (with status, error, IP) |
364+
| `list_bases` | List declared base pixels and their status |
365+
| `start_sandbox` / `stop_sandbox` / `destroy_sandbox` | Lifecycle |
366+
| `exec` | Run a command inside a sandbox |
367+
| `write_file` | Create or fully overwrite a file |
368+
| `read_file` | Read a file (optional truncation via `max_bytes`) |
369+
| `edit_file` | Replace `old_string` with `new_string` (with optional `replace_all`) |
370+
| `delete_file` | Remove a file |
371+
| `list_files` | List directory contents (optionally recursive) |
372+
373+
### Base pixels
374+
375+
A base is a container that sandboxes clone from. Bases are declared in
376+
config (or shipped as defaults) and built on demand.
377+
378+
Three bases ship out of the box:
379+
380+
- `dev` — Ubuntu 24.04 + git, curl, wget, jq, vim, build-essential
381+
- `python``dev` + python3, pip, pipx, venv
382+
- `node``dev` + Node 22 LTS, npm
383+
384+
Add your own in config. Each base must declare exactly one of `parent_image` or `from`:
385+
386+
```toml
387+
[mcp.bases.rust]
388+
parent_image = "ubuntu/24.04"
389+
setup_script = "~/.config/pixels/bases/rust.sh"
390+
description = "Rust toolchain"
391+
```
392+
393+
Or build on top of another base:
394+
395+
```toml
396+
[mcp.bases.rust-dev]
397+
from = "dev"
398+
setup_script = "~/.config/pixels/bases/rust-dev.sh"
399+
description = "Rust toolchain + dev tools"
400+
```
401+
402+
Bases form a DAG via `from`. Cycle / missing-dep / both-set / neither-set
403+
are rejected at config load.
404+
405+
Customise a base by mutating its container:
406+
407+
```bash
408+
pixels start px-base-python
409+
pixels exec px-base-python -- apt install vim
410+
pixels stop px-base-python
411+
pixels checkpoint create px-base-python
412+
```
413+
414+
The next `create_sandbox(base="python")` call clones from the new
415+
checkpoint. Existing sandboxes are unaffected (independent containers).
416+
417+
**Checkpoint-advances-clone-source.** Any `pixels checkpoint create px-base-X`
418+
immediately advances the snapshot that future sandboxes clone from. If you take
419+
a *safety* checkpoint before mutating, new sandboxes will clone from that
420+
pre-mutation state until you take another checkpoint *after* the changes. Always
421+
re-checkpoint after mutating to ensure new clones pick up your changes.
422+
423+
**Mutation-propagation gotcha.** Changes to `dev` do NOT auto-flow into
424+
`python` or `node`. Both are independent containers built when `dev` was
425+
in its prior state. To propagate: `pixels destroy px-base-python &&
426+
pixels base build python`. Same semantics as Docker layered images.
427+
428+
CLI:
429+
430+
| Command | Action |
431+
|---|---|
432+
| `pixels base list` | Show declared bases + status |
433+
| `pixels base build <name>` | Build the base; cascade-builds missing deps |
434+
| `pixels destroy px-base-<name>` | Delete a base (existing CLI) |
435+
| `pixels checkpoint create px-base-<name>` | Publish a new state for clones |
436+
437+
Example `pixels base list` output:
438+
439+
```
440+
$ pixels base list
441+
NAME FROM/IMAGE STATUS LAST_CHECKPOINT DESCRIPTION
442+
dev ubuntu/24.04 ready 2026-04-27 12:30:00 Ubuntu 24.04 + git, curl, vim, ...
443+
node dev missing dev + Node 22 LTS, npm
444+
python dev ready 2026-04-27 12:35:00 dev + python3, pip, pipx, venv
445+
```
446+
447+
**Force rebuild.** There is no `pixels base rebuild` command. To force a full
448+
rebuild of a base, run `pixels destroy px-base-<name> && pixels base build <name>`.
449+
450+
### Provisioning is async
451+
452+
`create_sandbox` returns immediately with `status: "provisioning"`.
453+
The agent should poll `list_sandboxes` until status flips to `running`
454+
or `failed`. A failed sandbox includes an `error` field describing
455+
what went wrong.
456+
457+
For simple use without a base, provisioning takes ~30s. With a built
458+
base, ~5s. With an unbuilt base, several minutes (the build runs
459+
behind the scenes).
460+
461+
### Lifetimes
462+
463+
Two TTLs apply (configurable in `[mcp]` config):
464+
465+
- `idle_stop_after` (default 1h) — running sandbox with no recent
466+
activity gets stopped.
467+
- `hard_destroy_after` (default 24h) — any sandbox older than this is
468+
destroyed and removed from state.
469+
331470
## Security
332471

333472
Container egress filtering uses nftables rules inside the container. A root process with `cap_net_admin` could bypass these rules. The `pixel` user has restricted sudo that only permits safe-apt, dpkg-query, systemctl, journalctl, and nft list.

cmd/base.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"io"
8+
"path/filepath"
9+
"slices"
10+
"time"
11+
12+
"github.com/briandowns/spinner"
13+
mcppkg "github.com/deevus/pixels/internal/mcp"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
var baseCmd = &cobra.Command{
18+
Use: "base",
19+
Short: "Manage pixel bases (templates that sandboxes clone from)",
20+
}
21+
22+
var baseListCmd = &cobra.Command{
23+
Use: "list",
24+
Short: "List declared bases and their status",
25+
RunE: runBaseList,
26+
}
27+
28+
var baseBuildCmd = &cobra.Command{
29+
Use: "build <name>",
30+
Short: "Build a base from its setup script (cascade-builds dependencies if missing)",
31+
Args: cobra.ExactArgs(1),
32+
RunE: runBaseBuild,
33+
}
34+
35+
func init() {
36+
baseCmd.AddCommand(baseListCmd)
37+
baseCmd.AddCommand(baseBuildCmd)
38+
rootCmd.AddCommand(baseCmd)
39+
}
40+
41+
func runBaseList(cmd *cobra.Command, args []string) error {
42+
if cfg == nil {
43+
return fmt.Errorf("config not loaded")
44+
}
45+
sb, err := openSandbox()
46+
if err != nil {
47+
return err
48+
}
49+
defer sb.Close()
50+
51+
// Load state from disk to check for build failures.
52+
state, err := mcppkg.LoadState(cfg.MCPStateFile())
53+
if err != nil {
54+
return err
55+
}
56+
57+
// Collect and sort base names for deterministic output.
58+
names := make([]string, 0, len(cfg.MCP.Bases))
59+
for name := range cfg.MCP.Bases {
60+
names = append(names, name)
61+
}
62+
slices.Sort(names)
63+
64+
w := newTabWriter(cmd)
65+
defer w.Flush()
66+
fmt.Fprintln(w, "NAME\tFROM/IMAGE\tSTATUS\tLAST_CHECKPOINT\tDESCRIPTION")
67+
68+
for _, name := range names {
69+
b := cfg.MCP.Bases[name]
70+
container := mcppkg.BaseName(cfg, name)
71+
fromOrImage := b.ParentImage
72+
if b.From != "" {
73+
fromOrImage = "from:" + b.From
74+
}
75+
76+
var status, lastChk string
77+
// Check container existence.
78+
_, err := sb.Get(context.Background(), container)
79+
if err != nil {
80+
status = "missing"
81+
} else {
82+
status = "ready"
83+
if latest, ok, err := mcppkg.LatestCheckpointFor(context.Background(), sb, container); err == nil && ok {
84+
lastChk = latest.CreatedAt.UTC().Format("2006-01-02 15:04:05Z")
85+
}
86+
}
87+
88+
// Check for cached build failures in the on-disk state.
89+
// A failed sandbox indicates a prior build failure.
90+
if sbState, ok := state.Get(container); ok && sbState.Status == "failed" {
91+
status = "failed"
92+
}
93+
94+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", name, fromOrImage, status, lastChk, b.Description)
95+
}
96+
return nil
97+
}
98+
99+
func runBaseBuild(cmd *cobra.Command, args []string) error {
100+
name := args[0]
101+
if cfg == nil {
102+
return fmt.Errorf("config not loaded")
103+
}
104+
if _, ok := cfg.MCP.Bases[name]; !ok {
105+
return fmt.Errorf("base %q not declared in config", name)
106+
}
107+
sb, err := openSandbox()
108+
if err != nil {
109+
return err
110+
}
111+
defer sb.Close()
112+
113+
stderr := cmd.ErrOrStderr()
114+
115+
// Spinner for non-verbose mode — shows current phase on stderr.
116+
var spin *spinner.Spinner
117+
if !verbose {
118+
spin = spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(stderr))
119+
}
120+
setStatus := func(msg string) {
121+
if spin != nil {
122+
spin.Suffix = " " + msg
123+
if !spin.Active() {
124+
spin.Start()
125+
}
126+
}
127+
}
128+
stopSpinner := func() {
129+
if spin != nil && spin.Active() {
130+
spin.Stop()
131+
}
132+
}
133+
defer stopSpinner()
134+
135+
exists := func(container string) bool {
136+
_, err := sb.Get(context.Background(), container)
137+
return err == nil
138+
}
139+
140+
build := func(baseName string) error {
141+
baseCfg := cfg.MCP.Bases[baseName]
142+
143+
// File-lock per base name across CLI vs daemon.
144+
lockDir := filepath.Dir(cfg.MCPStateFile())
145+
bl, err := mcppkg.AcquireBuildLock(lockDir, baseName)
146+
if err != nil {
147+
return err
148+
}
149+
defer bl.Release()
150+
151+
// Cascade-build header (visible in both modes).
152+
stopSpinner()
153+
fmt.Fprintf(stderr, "==> Building base %s...\n", baseName)
154+
155+
// Choose Out writer + Progress callback per verbose mode.
156+
var out io.Writer
157+
var captured *bytes.Buffer
158+
var progress func(string)
159+
if verbose {
160+
out = stderr
161+
progress = func(phase string) { fmt.Fprintf(stderr, "==> %s\n", phase) }
162+
} else {
163+
captured = &bytes.Buffer{}
164+
out = captured
165+
progress = setStatus
166+
}
167+
168+
start := time.Now()
169+
err = mcppkg.BuildBase(context.Background(), sb, cfg, baseName, baseCfg, mcppkg.BuildBaseOpts{
170+
Out: out,
171+
Progress: progress,
172+
})
173+
stopSpinner()
174+
if err != nil {
175+
// In non-verbose mode, dump captured script output before the error so the user
176+
// can see why the script failed.
177+
if captured != nil && captured.Len() > 0 {
178+
fmt.Fprintln(stderr, "--- captured output ---")
179+
_, _ = io.Copy(stderr, captured)
180+
fmt.Fprintln(stderr, "--- end output ---")
181+
}
182+
return err
183+
}
184+
fmt.Fprintf(stderr, "Built %s in %s\n", baseName, time.Since(start).Truncate(100*time.Millisecond))
185+
return nil
186+
}
187+
188+
return mcppkg.BuildChain(context.Background(), cfg, name, exists, build)
189+
}

cmd/create.go

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -122,36 +122,15 @@ func runCreate(cmd *cobra.Command, args []string) error {
122122
setStatus(fmt.Sprintf("Cloning from %s checkpoint %q...", fromSource, fromLabel))
123123
}
124124

125-
// Create a bare container (no provisioning/SSH wait) — we'll replace its rootfs.
126-
logv(cmd, "Creating bare container %s...", name)
127-
_, err := sb.Create(ctx, sandbox.CreateOpts{
128-
Name: name,
129-
Image: image,
130-
CPU: cpu,
131-
Memory: memory * 1024 * 1024,
132-
Bare: true,
133-
})
134-
if err != nil {
135-
return fmt.Errorf("creating instance: %w", err)
136-
}
137-
138-
logv(cmd, "Stopping %s for rootfs replacement...", name)
139-
if err := sb.Stop(ctx, name); err != nil {
140-
return fmt.Errorf("stopping %s for clone: %w", name, err)
141-
}
142-
143-
logv(cmd, "Cloning snapshot %s:%s...", fromSource, fromLabel)
125+
logv(cmd, "Cloning from %s:%s...", fromSource, fromLabel)
144126
if err := sb.CloneFrom(ctx, fromSource, fromLabel, name); err != nil {
145-
_ = sb.Delete(ctx, name)
146-
return fmt.Errorf("cloning checkpoint: %w", err)
147-
}
148-
149-
if err := sb.Start(ctx, name); err != nil {
150-
return fmt.Errorf("starting %s: %w", name, err)
127+
return fmt.Errorf("cloning from %s: %w", fromSource, err)
151128
}
152129

153130
setStatus("Waiting for SSH...")
154-
_ = sb.Ready(ctx, name, 30*time.Second)
131+
if err := sb.Ready(ctx, name, 30*time.Second); err != nil {
132+
return fmt.Errorf("waiting for %s: %w", name, err)
133+
}
155134
} else {
156135
// Normal create flow — sandbox handles provisioning, IP poll, SSH wait.
157136
setStatus("Creating...")

0 commit comments

Comments
 (0)