Skip to main content
Glama
sidebutton

computer-use

by sidebutton
README.md
# plugin-computer-use

A **persistent stdio MCP server** that exposes the Anthropic
[computer-use](https://docs.anthropic.com/en/docs/build-with-claude/computer-use)
action surface (screenshot, click, move, keyboard, clipboard, batch) against the
SideButton agent desktop on `DISPLAY=:10`.

This repo is the **scaffold + dispatch core** for the Computer Use epic
([SCRUM-1399](https://aictpo.atlassian.net/browse/SCRUM-1399)). It is delivered
by [**SCRUM-1397**](https://aictpo.atlassian.net/browse/SCRUM-1397):

- the long-lived stdio MCP server loop (`initialize` / `tools/list` / `tools/call`),
- the ported `computer.py` **dispatch base** (DISPLAY targeting, screenshot →
  base64 PNG, coordinate scaling, single-owner lock, xdotool runner),
- the full tool surface **declared** so `tools/list` returns it,
- `screenshot` **wired end-to-end** as the proof action.

The individual tool **bodies** land in sibling tickets (SCRUM-1400…1405) and
hosting this as a `runtime: "service"` plugin is SCRUM-1406.

## Why a persistent server

The current SideButton plugin model
([the-assistant `packages/server/src/plugins`](https://github.com/maxsv0/the-assistant))
spawns a **fresh, stateless** handler process per `tools/call` and SIGKILLs it at
a 30s timeout. That cannot host the computer-use surface, which needs cross-call
state: a held mouse button (`left_mouse_down` … `left_mouse_up`), the
screenshot→coordinate session, session grants, and holds up to ~100s. So this is
a **single, long-lived child process** that speaks MCP over stdio.

## Tool surface

24 tools, grouped by the sibling ticket that owns each body. The **capture group**
(`screenshot`, `zoom`, SCRUM-1400), the **click group** (`left_click` / `right_click`
/ `middle_click` / `double_click` / `triple_click`, SCRUM-1401), the **keyboard
group** (`type` / `key` / `hold_key`, SCRUM-1403), and the **clipboard + session
group** (SCRUM-1404) are implemented; the rest are declared and return a clear
pending-owner error until their ticket lands. Full input schemas:
[`docs/computer-use-mcp-tools-schema.md`](docs/computer-use-mcp-tools-schema.md).

| Group | Ticket | Tools |
| --- | --- | --- |
| capture | SCRUM-1400 | `screenshot` ✅, `zoom` ✅ |
| click | SCRUM-1401 | `left_click` ✅, `right_click` ✅, `middle_click` ✅, `double_click` ✅, `triple_click` ✅ |
| move / drag / scroll | SCRUM-1402 | `mouse_move`, `left_click_drag`, `scroll`, `left_mouse_down`, `left_mouse_up` |
| keyboard | SCRUM-1403 | `type` ✅, `key` ✅, `hold_key` ✅ |
| clipboard + session | SCRUM-1404 | `read_clipboard` ✅, `write_clipboard` ✅, `request_access` ✅, `list_granted_applications` ✅, `open_application` ✅, `switch_display` ✅ |
| utility / batch | SCRUM-1405 | `computer_batch`, `wait`, `cursor_position` |

### Clipboard + session behaviour (SCRUM-1404)

The macOS session/permission model has no XFCE/Xvfb equivalent, so these
**degrade gracefully instead of erroring** — keeping cross-runner (macOS-authored)
skills working — while honouring the native grant flags so call shapes match:

- `request_access` **auto-grants** the requested `apps` (no compositor dialog),
  records the `clipboardRead` / `clipboardWrite` / `systemKeyCombos` flags
  (additive across calls), and returns `screenshotFiltering: false`.
- `list_granted_applications` echoes the allowlist + active grant flags.
- `read_clipboard` / `write_clipboard` shell out to
  `xclip -selection clipboard`, **gated** on the `clipboardRead` / `clipboardWrite`
  grants (a call without the grant returns an `isError` result, matching native).
- `open_application` is **best-effort** window focus (`wmctrl -a`, then
  `xdotool search --name … windowactivate`); the primary target is the single
  RDP window. With neither binary installed it returns a non-error no-op note.
- `switch_display` is a **no-op** on the single Xvfb `:10` and reports the
  current display (accepts `"auto"`).

> **Surface count.** This is the **24-tool** surface the epic
> ([SCRUM-1399](https://aictpo.atlassian.net/browse/SCRUM-1399)) specifies. The
> clipboard + session group follows the explicit enumeration in
> [SCRUM-1404](https://aictpo.atlassian.net/browse/SCRUM-1404) (`read_clipboard` /
> `write_clipboard` split + `list_granted_applications`), which is the 2-tool
> delta over the work plan's interim count of 22. `src/tools.py` is the single
> source of truth; `docs/computer-use-mcp-tools-schema.md` (AC4) is generated
> from it.

> **Bare names + collisions.** Names are the canonical Anthropic action ids.
> `screenshot`, `type`, `scroll`, `wait`, `click` collide with **core** SideButton
> MCP tools, and the current loader drops the *entire* plugin on any collision.
> That is fine standalone (this server owns its namespace); **namespacing on
> aggregation is deferred to SCRUM-1406** (recommended: bare names in the child,
> prefix/slug-namespace on the host).

## Layout

```
plugin-computer-use/
├── plugin.json        # generated service-plugin manifest (proposes runtime:"service")
├── src/
│   ├── server.py      # stdio MCP loop: initialize / tools/list / tools/call
│   ├── computer.py    # dispatch base (ported computer.py)
│   └── tools.py       # canonical tool surface (single source of truth)
├── scripts/
│   └── build_manifest.py   # regenerates plugin.json + the schema doc from tools.py
├── tests/             # unittest: dispatch-base unit + stdio round-trip + manifest
├── docs/
│   └── computer-use-mcp-tools-schema.md   # generated; the AC4 schema doc
├── run_tests.sh       # runs the suite (xvfb-wrapped when no DISPLAY)
├── pyproject.toml     # dependency-free, python>=3.10
├── README.md  LICENSE  .gitignore
```

## Run it standalone

```bash
# speak MCP by hand (newline-delimited JSON-RPC):
printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
  '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"screenshot","arguments":{"save_to_disk":true}}}' \
  '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"zoom","arguments":{"region":[600,300,900,500]}}}' \
  '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"type","arguments":{"text":"hello"}}}' \
  '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"key","arguments":{"text":"ctrl+a","repeat":1}}}' \
  '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"hold_key","arguments":{"text":"shift","duration":2}}}' \
  '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"left_click","arguments":{"coordinate":[600,300],"text":"ctrl"}}}' \
  | DISPLAY=:10 python3 src/server.py
```

`initialize` returns the handshake, `tools/list` the 24-tool surface, the
`screenshot` call a base64 PNG image block (plus a `Saved to disk: <path>` text
block when `save_to_disk` is set), and `zoom` a magnified PNG of the region. The
keyboard and click calls each return a short text ack (`isError:false`); they need
`xdotool` on `PATH`. The `left_click` maps its `[600, 300]` against the `id:3`
screenshot's coordinate session — a click before any screenshot returns a clear
`no screenshot session yet` error instead of clicking blind.

## Capture & coordinates

`screenshot` captures `DISPLAY=:10` and, when the measured size matches a model
resolution, downscales it (on the live 1920×1080 `:10` it returns **1366×768**).
Each capture records a **screenshot → coordinate session**: the *measured* device
geometry and the returned image geometry. Coordinates the model returns are in
**image space** (relative to the last screenshot); the server maps them back to
device pixels via `Computer.to_device(x, y)` — the foundation the click/move
siblings (SCRUM-1401/1402) consume. Both the downscale and the coordinate mapping
are derived from the *same measured geometry*, so they can never use different
bases (the wrong-pixel-click failure mode).

`zoom` takes `region: (x0, y0, x1, y1)` in image space, maps it to a device rect,
and crops it from a **fresh full-resolution** capture — genuine magnification, not
an upscale of the downscaled screenshot. It is **read-only**: it never moves the
click-coordinate origin (clicks still refer to the last `screenshot`). If no
screenshot has been taken yet, `zoom` establishes the session lazily.

True 1:1 (no downscale) would require pinning `:10` / the RDP window to a
model-friendly size — that is provisioning ([SCRUM-1396](https://aictpo.atlassian.net/browse/SCRUM-1396)),
out of scope here.

### Click group (SCRUM-1401)

Pointer clicks at a **screenshot-session coordinate**. The `[x, y]` `coordinate`
is image space (relative to the last `screenshot`) and is mapped to device pixels
via `Computer.to_device` — a click **before any screenshot** returns a clear
`no screenshot session yet` error (look before you click). Optional `text`
modifier(s) (`'ctrl'`, `'shift+alt'`, …) are held for the click and **always
released** (`keyup` in a `finally`, the same guarantee as `hold_key`).

| Tool | xdotool | Button |
| --- | --- | --- |
| `left_click` | `mousemove --sync <dx> <dy> click 1` | left (1) |
| `right_click` | `mousemove --sync <dx> <dy> click 3` | right (3) |
| `middle_click` | `mousemove --sync <dx> <dy> click 2` | middle (2) |
| `double_click` | `mousemove --sync <dx> <dy> click --repeat 2 --delay 100 1` | left ×2 |
| `triple_click` | `mousemove --sync <dx> <dy> click --repeat 3 --delay 100 1` | left ×3 |

With a modifier the click is wrapped in `keydown -- <text>` → click → `keyup --
<text>`. On-screen pixel accuracy is validated live in
[SCRUM-1408](https://aictpo.atlassian.net/browse/SCRUM-1408) (`xdotool` is absent
on the current runner image, so the unit tests assert the device-pixel argv).

### Keyboard group (SCRUM-1403)

| Tool | xdotool | Notes |
| --- | --- | --- |
| `type` | `xdotool type --delay 12 -- <text>` | types `text` at the current focus |
| `key` | `xdotool key --repeat <repeat> -- <text>` | chords, e.g. `ctrl+s`; optional `repeat` (default 1) |
| `hold_key` | `keydown -- <text>` → `sleep <duration>` → `keyup -- <text>` | the hold runs in the persistent server (Python `time.sleep`), so durations up to ~100s do not trip the per-call subprocess timeout; `keyup` runs in a `finally` so a held key/modifier is always released |

## Test

```bash
./run_tests.sh          # uses $DISPLAY if set, else wraps in xvfb-run
# or directly:
DISPLAY=:10 python3 -m unittest discover -s tests -v
```

- `tests/test_dispatch_base.py` — coordinate-scaling math, the screenshot →
  coordinate session + `to_device` mapping, the measured-basis downscale target,
  `zoom` region validation + region→device-rect math, xdotool command
  construction, single-owner lock, screenshot-backend detection, surface shape,
  plus live `screenshot`/`zoom` + `save_to_disk` (DISPLAY-gated).
- `tests/test_stdio_roundtrip.py` — `initialize` → `tools/list` → `tools/call`
  `screenshot` (incl. `save_to_disk` path block) and `zoom` over a spawned server,
  plus error paths.
- `tests/test_manifest.py` — `plugin.json` + schema doc are present and in sync
  with `src/tools.py`.

The screenshot round-trip needs an X display; `run_tests.sh` provides one via
`xvfb-run` when `$DISPLAY` is unset, so AC3 still exercises in headless CI.

## System dependencies

System packages (apt), not pip — the plugin install copies no `node_modules`/venv
and runs no build step, so the server is **stdlib-only** and shells out to:

| Tool | Used for | Notes |
| --- | --- | --- |
| a screenshot backend | `screenshot` | `gnome-screenshot` **or** `scrot` **or** ImageMagick (`import`/`convert`). The runner ships ImageMagick. |
| `xdotool` | pointer/keyboard actions; `open_application` fallback | required by the click/move/keyboard groups (siblings); absent on the runner image. |
| `xclip` | `read_clipboard` / `write_clipboard` | already on the runner; grant-gated. |
| `wmctrl` | `open_application` window focus | best-effort; `open_application` degrades to a no-op when absent. |

`scrot` and `gnome-screenshot` are **absent** on the runner image, so the
screenshot backend falls through to ImageMagick `import -window root` (verified
on `DISPLAY=:10`). When SCRUM-1407 adds this plugin to the agent-runners catalog,
declare `xdotool`, a screenshot backend, and `xclip` in its `system_deps`.

## DISPLAY and single-owner

- The server targets the **inherited `$DISPLAY`**, defaulting to `:10` (the
  runner desktop). It never hardcodes a display — the screen-record plugin's bug
  was capturing a non-existent `:1.0`.
- It takes a process-lifetime **single-owner lock** (`flock`,
  `/tmp/sidebutton-computer-use.lock`, override with `CU_LOCK_PATH`) so only one
  session drives the shared pointer/keyboard; a second instance exits non-zero.

## Service-manifest contract (SCRUM-1406)

`plugin.json` targets the merged `runtime: "service"` tier: the SideButton server
keeps the child alive, discovers its tools via `tools/list`, and forwards
`tools/call` to it.

```jsonc
{
  "name": "computer-use",
  "runtime": "service",
  "service": {
    "command": "python3 src/server.py",  // non-empty string; the engine splits on
                                          // whitespace and spawns with cwd=plugin dir
    "toolNamespace": "computer_use",      // tools surface as computer_use_<tool>
    "tools": {                            // per-tool timeout overrides (ms)
      "hold_key": { "timeoutMs": 120000 },
      "wait":     { "timeoutMs": 120000 }
    }
  },
  "tools": []                             // service plugins declare no static tools
}
```

> The loader (`the-assistant` `packages/server/src/plugins/loader.ts`) recognizes
> only `command` / `timeoutMs` / `toolNamespace` / `tools` under `service`, and
> **hard-rejects** the manifest unless `command` is a non-empty **string** — an
> array fails validation and the plugin never loads. Tools are discovered live,
> so the top-level `tools` array is normalized to `[]`. This repo owns only
> `plugin.json`; the agent-runners catalog entry + `system_deps` are SCRUM-1407.

## Configuration (env)

| Var | Default | Purpose |
| --- | --- | --- |
| `DISPLAY` | `:10` | target X display |
| `CU_WIDTH` / `CU_HEIGHT` | `1920` / `1080` | screen size for coordinate scaling |
| `CU_SCREENSHOT_DELAY` | `2.0` | post-action settle before a screenshot |
| `CU_LOCK_PATH` | `/tmp/sidebutton-computer-use.lock` | single-owner lock file |
| `CU_SAVE_DIR` | `/tmp/sidebutton-computer-use/` | where `save_to_disk` writes shareable PNGs (host-pruned; saved files are not auto-deleted) |

## License

MIT © 2026 SideButton

TDQS

B3.2/5.0

Scored across 24 tools

Disambiguation4/5

Most tools have distinct purposes like left_click vs right_click vs double_click, but some overlap exists (e.g., left_click_drag vs left_mouse_down/move/up sequence) and could cause confusion despite clear descriptions.

Naming Consistency3/5

Naming is mostly snake_case but inconsistent in verb_noun patterns: some are verbs (type, scroll), some nouns (screenshot, key), and some are compound (computer_batch). Not chaotic but lacks a uniform style.

Tool Count4/5

24 tools is reasonable for a computer-use server covering mouse, keyboard, clipboard, screenshots, and display management. Not too many, not too few.

Completeness4/5

Covers core desktop automation actions well (clicks, drag, keyboard, clipboard, screenshots, scrolling, application launching). Minor gaps like file operations or advanced gestures are acceptable.

Maintenance

ActivityInactive
ResponsivenessNo issues