Skip to main content
Glama
README.md
# qsmcp

An MCP server for developing [Quickshell](https://quickshell.org) widgets with an
AI coding agent. Its headline tool renders **one widget** to a PNG — offscreen,
sandboxed, without capturing your screen and without touching your running shell.

The image comes back in the tool result, so the model actually sees what it built.

## Why

Iterating on a Quickshell widget normally means restarting the whole shell and
screenshotting the entire desktop. That is slow, leaks everything else on
screen, and disturbs the live session. `qs_preview` renders just the component.

The render primitive is Qt's `Item.grabToImage(callback, targetSize)`, which
re-renders the item subtree into a framebuffer object. It is **not** a screen
grab and **not** a window readback, and it works on an item far larger than its
host window — a 1920×40 bar is captured from an 8×8 window.

## Requirements

- Quickshell (`qs`) and Qt 6
- Node 22+ — Node strips the TypeScript natively, so there is **no build step**
- `kwin_wayland` (optional) — only needed to preview `PanelWindow`-rooted
  components

## Install

```sh
git clone https://github.com/fedsfarm/qsmcp ~/Projects/qsmcp
cd ~/Projects/qsmcp && npm install
```

Register it with Claude Code, project-scoped so it only loads inside your shell
repo. `QSMCP_SHELL_ROOT` is the directory holding your shell's root QML file:

```sh
cd ~/my-shell-repo
claude mcp add qsmcp --scope local \
  -e QSMCP_SHELL_ROOT=$HOME/my-shell-repo/quickshell \
  -- node $HOME/Projects/qsmcp/src/index.ts serve
```

Or write it into `.mcp.json` / any MCP client config by hand:

```json
{
  "mcpServers": {
    "qsmcp": {
      "type": "stdio",
      "command": "node",
      "args": ["/home/you/Projects/qsmcp/src/index.ts", "serve"],
      "env": { "QSMCP_SHELL_ROOT": "/home/you/my-shell-repo/quickshell" }
    }
  }
}
```

With `QSMCP_SHELL_ROOT` unset it falls back to `$XDG_CONFIG_HOME/quickshell`.

Verify the handshake without a client:

```sh
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
  | QSMCP_SHELL_ROOT=~/my-shell-repo/quickshell node src/index.ts serve
```

## Tools

| tool | what it does |
|---|---|
| `qs_preview` | Render one component to a PNG and return the image plus structured QML diagnostics |
| `qs_symbol` | A component's root type, imports, properties, signals, functions, and a preview hint |
| `qs_list_components` | List components with root types, optionally filtered |
| `qs_live_instances` | **Read-only.** Running Quickshell instances |
| `qs_live_ipc_describe` | **Read-only.** `qs ipc show` — the live IPC surface |
| `qs_live_prop_get` | **Read-only.** `qs ipc prop get` |
| `qs_live_logs` | **Read-only.** `qs log -t N`, never follows |

No tool mutates the running shell. There is deliberately no `ipc call`, no
reload and no screen capture.

### `qs_preview` arguments

| arg | meaning |
|---|---|
| `component` | Bare name (`"ClockWidget"`), path relative to the shell root (`"components/Bar.qml"`), or an absolute path |
| `source` | Raw QML to render *instead* of an existing component. A complete document; relative imports like `import "../config"` resolve normally, and nothing is written to your tree |
| `props` | Property values to set on the component, e.g. `{"open": true, "tab": "notepad"}` |
| `width` / `height` | Explicit logical size. Omit to use the component's implicit size |
| `dpr` | Device pixel ratio, 0.25–4. Default 2 |
| `background` | `theme` (default, the shell's panel colour), `solid`, `checker`, `transparent` |
| `bg_color` | Colour for `background: "solid"` |
| `padding` | Logical px around the component. Default 12 |
| `model_data` | Value for a required `modelData` property; `"screen:0"` for the first monitor. Auto-filled when the component requires it |
| `pre_script` | QML evaluated before mounting, e.g. `"Anim.enabled = false"` to freeze animations |
| `ready_expr` | QML that must be true before capture, e.g. `"Config.ready && Theme.themesLoaded"` |
| `max_ms` | Deadline for reaching a stable frame. Default 8000 |
| `save_to` | Also copy the PNG to this path |

Two traps worth knowing:

- A component gated on an `open`/`visible` property renders as a **solid
  rectangle** and still reports `ok: true`. Pass the gating props, or you are
  looking at a confidently-empty image. `qs_symbol` lists which props exist.
- `width` also sets the **virtual screen width** for `PanelWindow` components,
  and they size themselves against it. Pass the real monitor width and crop
  afterwards rather than shrinking the surface.

## How isolation works

**Offscreen (default).** The generated harness sets its own environment before
`QGuiApplication` exists, using Quickshell's pragma block:

```
//@ pragma Env QT_QPA_PLATFORM=offscreen
//@ pragma ShellId qsmcp
//@ pragma DataDir/StateDir/CacheDir <scratch>
```

The server additionally unsets `WAYLAND_DISPLAY` and
`HYPRLAND_INSTANCE_SIGNATURE`, so the Wayland backend and Hyprland IPC stay
down — a preview cannot map a surface, capture a screen or dispatch a
compositor command. `DISPLAY` is kept, because `libqoffscreen` has a GLX path
(`QOffscreenX11GLXContext`) that gives hardware GL, which is what makes
`ShaderEffect` and `MultiEffect` blur render correctly. No window is ever
mapped on it.

**Nested.** `PanelWindow` cannot even be constructed offscreen ("No PanelWindow
backend loaded"), so window-rooted components get a private `kwin_wayland
--virtual` instance on its own `XDG_RUNTIME_DIR`. KWin implements
`zwlr_layer_shell_v1`, so the type resolves. That virtual output exposes no
usable EGL config to clients, so Qt Quick falls back to the software renderer
there — reported as `degraded: [shadereffect, blur, layer_effects]` rather than
silently producing a flat image. **Blur fidelity still means a real session.**

## The overlay

`Quickshell.shellDir` is the directory of the root QML file, and real configs
derive `shellDir + "/themes"` and `shellDir + "/scripts"` from it. So the
harness runs from an **overlay**: a scratch directory under
`$XDG_RUNTIME_DIR/qsmcp/<hash>/shell` that mirrors your tree file-by-file.

That buys three things:

1. **Script stubs.** Scripts that apply GTK/Qt/kitty themes, set wallpaper or
   change hardware are replaced with executable no-ops. The stub must *exist*,
   not be absent — some configs fall back to a hardcoded absolute path when the
   `shellDir` copy is missing, so a missing stub would silently run the real
   theme applier. Anything a preview tried to invoke is reported as
   `side_effects_blocked`. Only executables are ever stubbed; QML is never
   touched.
2. **Correct relative imports.** A file loaded as
   `<overlay>/components/Foo.qml` resolves `import "../config"` against its
   URL, so singletons instantiate exactly once. Mixing real and overlay URLs
   would create two `Theme` objects.
3. **A shadow `$HOME`.** Per-entry symlinks to your real home, except the
   config directories your shell writes to, which are real copies — so
   `Config.set()` and equivalents land in scratch. Directories referenced via
   `Quickshell.env("HOME") + "/..."` are detected automatically; large trees
   (wallpaper libraries) stay symlinked.

Nothing is ever written under your shell tree. `$XDG_RUNTIME_DIR` is tmpfs, so
logout is a hard reset.

## Settle

Previews never sleep a fixed interval. The harness waits for `ready_expr`, then
grabs a downscaled probe frame every 120 ms and requires two byte-identical
frames before the real capture. A component that never settles (a clock with
seconds, a spinner) returns the image with `settled: false` and
`reason: "timeout"` rather than pretending. `pre_script: "Anim.enabled = false"`
usually fixes it.

## CLI

The same renderer without a client, for scripting or debugging:

```sh
QSMCP_SHELL_ROOT=~/my-shell/quickshell node src/index.ts render ClockWidget --out /tmp/clock.png
QSMCP_SHELL_ROOT=~/my-shell/quickshell node src/index.ts render Bar --width 1920 --height 40 --dpr 1 --padding 0
node src/index.ts render --help
node src/index.ts info      # resolved config, scratch paths, detected copy paths
```

The CLI writes JSON to stdout and progress to stderr; you then have to open the
PNG yourself. Through MCP the image is in the tool result, which is why the
server is the intended path.

## Environment

| var | effect |
|---|---|
| `QSMCP_SHELL_ROOT` | Directory holding the shell's root QML. Defaults to `$XDG_CONFIG_HOME/quickshell` |
| `QSMCP_QS_BIN` | Path to the `qs` binary. Default `qs` |
| `QSMCP_STUB_EXTRA` | Comma-separated script names to stub on top of the defaults |
| `QSMCP_STUB_EXEMPT` | Comma-separated script names to force-keep live |
| `QSMCP_ALLOW_MUTATING` | `1` disables the stub layer entirely. Off by default, and off is the point |

## Known limits

- Contact sheets, synthetic hover/press (`QtTest`), and a warm daemon are not
  implemented; every render is a cold spawn (~2 s).
- The Quickshell API knowledge layer (parsing `*.qmltypes` and the annotated
  headers) is not built yet.
- `qmllint` integration is not wired. Note that `/usr/bin/qmllint` is often Qt
  5.15; the usable one is `/usr/lib/qt6/bin/qmllint`.
- Nested (window-rooted) previews are software-rendered, so blur and shader
  effects do not appear.

`probes/RESULTS.md` records the containment and non-interference probes the
design is built on.

## License

GPL-3.0-or-later. See [LICENSE](LICENSE).