Skip to main content
Glama
README.md
# quickshell-mcp

An MCP server that gives a coding agent hands and eyes on a quickshell (QML) app.

Point it at your app. The agent sees screenshots, reads the widget tree, and
changes QML state, so it can build a UI and check its own work instead of
asking you what the screen looks like.

## What the agent does with it

The server drives one app in a loop:

1. `screenshot` returns the window as a PNG.
2. `tree` and `find` map the widgets and their names.
3. `get_property`, `set_property`, `invoke`, `qml_eval`, and `macro` change state.
4. `screenshot` again shows the result.

The agent learns this loop when it connects.

Every observation puts a PNG in the agent's context.

## Does it fit your setup

Your app must be a quickshell config directory with a QML entry file.
`screenshot` needs each window's content item to hold exactly one visual child
that covers the window.

The agent reaches a widget by its `objectName`, or by a child-index path from
the window root. A path changes when you edit the layout, so set `objectName`
on every widget the agent should drive.

The server runs the app under a headless `sway` with the pixman software
renderer, so it works over SSH and in CI. Linux only, because the app needs a
wayland compositor with layer-shell.

Tested on x86_64-linux and aarch64-linux, if you run other platforms, add i.e. `riscv64-linux` to `flake.nix`,
run `python examples/check.py`, and open a PR with the output.

Nix supplies every dependency: quickshell, sway, and cue.

One server drives one app. You can run more servers for iterating on multiple apps.

## Try it

Run the counter example:

```bash
nix run github:hsjobeki/quickshell-mcp#demo
```

It starts a compositor, boots the counter app, drives it, and saves a
screenshot per step:

```
window 0: Counter, 1276x693
  count: 0 -> quickshell-mcp-demo/1-start.png
  count: 1 -> quickshell-mcp-demo/2-increment.png
  count: 41 -> quickshell-mcp-demo/3-macro.png
  count: 0 -> quickshell-mcp-demo/4-reset.png
```

Open `3-macro.png`. The label reads `count: 41`, set through the server. Those
four PNGs are what your agent sees.

## Connect it to your agent

Add the server to your MCP client config,
or ask your agent to do it:

```json
{
  "mcpServers": {
    "quickshell": {
      "command": "nix",
      "args": [
        "run",
        "github:hsjobeki/quickshell-mcp",
        "--",
        "--profile",
        "/absolute/path/to/your-app/profile.json"
      ]
    }
  }
}
```

The client spawns this command directly, without a shell, so the profile path
is absolute. It speaks MCP over stdio. `--profile` is required.

The server boots the app on the agent's first tool call and tears everything
down when the client disconnects. `nix run` re-evaluates the flake on every
start. To cut that from each session, run `nix build` once and point `command`
at the absolute path of `result/bin/quickshell-mcp`.

To point your agent at the counter example instead of your own app, use
`github:hsjobeki/quickshell-mcp#counter` as the flake reference above and drop
the `--` and `--profile` arguments.

## The profile

A profile is a small JSON file, one per app. It tells the server which config
directory to run, which entry file to load, which backend processes to start,
and which macros to offer.

The smallest profile runs a self-contained app:

```json
{ "config_dir": "." }
```

`config_dir` is the only required field. It is absolute, or relative to the
profile file. `entry` defaults to `shell.qml`.

Inside any string value, `${PROFILE_DIR}`, `${CONFIG_DIR}`, `${WORK}`, and
`${XDG_RUNTIME_DIR}` expand at boot.

A macro names a QML expression and its parameters, so the agent triggers an app
action by name instead of composing the expression:

```json
{
  "name": "set_count",
  "params": ["n"],
  "expr": "(function(){win(0).count=${n};return win(0).count;})()"
}
```

Every field lives in
[`quickshell_mcp/profile.cue`](quickshell_mcp/profile.cue), with a doc-comment
on each. The loader runs `cue vet` against that schema before boot, so a broken
profile fails at start with the schema error.

Generate a JSON Schema for your editor from the same source:

```bash
cue def --out jsonschema -e '#Profile' quickshell_mcp/profile.cue
```

## Examples

Each example doubles as an end-to-end test. Its `check.py` boots it for real
and asserts the states it renders.

```bash
python examples/check.py              # every example
python examples/check.py counter      # one of them
```

- `counter`: a tiny QML app with one macro.
- `async-form`: a form flow over a fake backend on a unix socket. It covers
  validation, keyed in-flight state, and a streaming setup flow with prompts.
  Its profile shows a backend with a readiness gate, a seeded fixture, and
  `env_out`. See `examples/async-form/README.md` for the state tables and the
  selectors.

## Contributing

Launch the dev-shell:

```bash
nix develop .
```

`pytest tests` runs the unit tests: they stub the harness, so they need no
compositor. `nix flake check` runs the same tests in a sandbox. The examples
double as the end-to-end checks and do boot a real compositor:
`python examples/check.py`.

`MCP_BACKEND_DELAY_MS` passes to every backend a profile spawns. A backend that
honours it slows async transitions enough to observe them between screenshots.

**The agent-facing instructions live in
[`quickshell_mcp/server.py`](quickshell_mcp/server.py).**