Metadata-Version: 2.4
Name: kwin-mcp
Version: 0.12.0
Summary: MCP server for Linux desktop GUI automation on KDE Plasma 6 Wayland — virtual testing and live desktop automation
Keywords: mcp,mcp-server,model-context-protocol,kwin,kde,wayland,wayland-automation,gui-automation,desktop-automation,plasma,testing,e2e-testing,headless-testing,ai-agents,accessibility,at-spi2,libei,linux-desktop,headless,claude-code,live-desktop,live-session,session-connect,desktop-automation-platform,virtual-session,screen-capture,kiosk-automation,embedded-desktop
Author: Byeonghoon Yoo
Author-email: Byeonghoon Yoo <bhyoo@bhyoo.com>
License-Expression: MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Topic :: Software Development :: Testing
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Desktop Environment :: K Desktop Environment (KDE)
Classifier: Environment :: X11 Applications :: KDE
Classifier: Typing :: Typed
Requires-Dist: mcp>=2.2.0,<3
Requires-Dist: pygobject>=3.42.0
Requires-Dist: dbus-python>=1.3.2
Requires-Dist: pillow>=10.0.0
Requires-Python: >=3.12
Project-URL: Homepage, https://pypi.org/project/kwin-mcp/
Project-URL: Repository, https://github.com/isac322/kwin-mcp
Project-URL: Issues, https://github.com/isac322/kwin-mcp/issues
Project-URL: Changelog, https://github.com/isac322/kwin-mcp/blob/main/CHANGELOG.md
Project-URL: Documentation, https://github.com/isac322/kwin-mcp#readme
Description-Content-Type: text/markdown

# kwin-mcp

**Model Context Protocol server for Linux desktop GUI automation on KDE Plasma 6 Wayland**

[![PyPI version](https://img.shields.io/pypi/v/kwin-mcp)](https://pypi.org/project/kwin-mcp/)
[![Downloads](https://img.shields.io/pypi/dm/kwin-mcp)](https://pypi.org/project/kwin-mcp/)
[![Python 3.12+](https://img.shields.io/pypi/pyversions/kwin-mcp)](https://pypi.org/project/kwin-mcp/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![CI](https://github.com/isac322/kwin-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/isac322/kwin-mcp/actions/workflows/ci.yml)

A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that enables AI agents (Claude Code, Cursor, and other MCP clients) to launch, interact with, and observe any Wayland application in an isolated virtual KWin session -- without affecting the user's desktop display or input. It also supports **live desktop automation** by connecting to an existing KWin session (real desktop or container) for collaborative workflows. With 37 MCP tools covering mouse, keyboard, touch, clipboard, accessibility tree inspection, screenshot capture, and window management, kwin-mcp provides everything needed for end-to-end GUI testing and desktop automation on Linux.

## Table of Contents

- [Why kwin-mcp?](#why-kwin-mcp)
- [Use Cases](#use-cases)
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [Available Tools](#available-tools)
- [How It Works](#how-it-works)
- [System Requirements](#system-requirements)
- [Installation](#installation)
- [Limitations](#limitations)
- [End-to-End Testing](#end-to-end-testing)
- [Contributing](#contributing)
- [License](#license)

## Why kwin-mcp?

- **Isolated sessions** -- Each session runs in its own `dbus-run-session` + `kwin_wayland --virtual` compositor. Your host desktop never receives its windows or input. This is not a filesystem or network sandbox; see [Isolation scope](#isolation-scope).
- **Live session support** -- Connect to a real KDE Plasma desktop or a KWin instance inside a container (e.g. `systemd-nspawn`) for collaborative "share my screen" workflows.
- **No screenshots required for interaction** -- The AT-SPI2 accessibility tree gives the AI agent structured widget data (roles, names, coordinates, states, available actions), so it can interact with UI elements without relying solely on vision.
- **Zero authorization prompts** -- Uses KWin's private EIS (Emulated Input Server) D-Bus interface directly, bypassing the XDG RemoteDesktop portal. No user confirmation dialogs.
- **Works with any Wayland app** -- Anything that runs on KDE Plasma 6 Wayland works: Qt, GTK, Electron, and more. Input is injected via the standard `libei` protocol.
- **Full input coverage** -- Mouse, keyboard, multi-touch, and clipboard -- all injected through the isolated session for complete desktop automation.

## Use Cases

### Automated GUI Testing

Run end-to-end GUI tests for KDE/Qt/GTK applications in headless isolated sessions. kwin-mcp launches each app in its own virtual KWin compositor, interacts via mouse, keyboard, and touch input, then verifies results through screenshots and the accessibility tree -- all without a physical display.

### AI-Driven Desktop Automation

Let AI agents like Claude Code autonomously operate desktop applications. The agent reads the accessibility tree to understand the UI, performs actions through 37 MCP tools, and observes the results via screenshots -- creating a complete feedback loop for any Wayland application.

### Live Desktop Collaboration

Connect to your real desktop session and let Claude observe and interact with what you see. Use `session_connect` or pass `--default-live-session` to make live mode the default. Also supports attaching to KWin running inside containers (e.g. `systemd-nspawn`) for isolated agent desktops.

### Headless GUI Testing in CI/CD

Integrate Linux desktop GUI testing into CI/CD pipelines. kwin-mcp's virtual sessions require no X11 or physical display server, making it suitable for headless environments like GitHub Actions or GitLab CI runners on Linux.

### Kiosk and Embedded Device Automation

Automate kiosk interfaces and embedded Linux desktops running KDE Plasma or a bare KWin Wayland compositor. Use `session_start` for isolated virtual testing of kiosk UIs, or `session_connect` to attach directly to a live kiosk or embedded device session for real-time automation and diagnostics.

## Quick Start

> Requires KDE Plasma 6 on Wayland. See [System Requirements](#system-requirements) for details.

> [!NOTE]
> **0.7.0 only:** the published kwin-mcp 0.7.0 package on PyPI did not cap its `mcp` dependency, so a fresh install could resolve `mcp` 2.x and the server failed at startup with `ModuleNotFoundError: No module named 'mcp.server.fastmcp'`. Releases 0.8.0–0.9.x cap `mcp<2`, and 0.10.0 and later require `mcp>=2.2.0,<3`. If you are still on 0.7.0, upgrade kwin-mcp before following the steps below.

**1. Install system and build dependencies**

`uv tool install` and `uvx` always install kwin-mcp into an isolated Python environment, and `pip install` does the same when run inside a virtual environment. An isolated environment cannot reuse your distribution's `python3-gi` or `python3-dbus` packages, so PyGObject, pycairo, and dbus-python are built from source during installation and need a C compiler and development headers. Install the packages from [Installing System Dependencies](#installing-system-dependencies) first. On Debian 13 (Trixie):

```bash
sudo apt-get install -y --no-install-recommends build-essential pkg-config python3-dev libcairo2-dev libgirepository-2.0-dev libdbus-1-dev
```

**2. Install** (uv itself: see the [uv installation guide](https://docs.astral.sh/uv/getting-started/installation/))

```bash
# Using uv (recommended)
uv tool install kwin-mcp

# Or using pip
pip install kwin-mcp
```

**3. Configure Claude Code**

Add to your project's `.mcp.json`:

```json
{
  "mcpServers": {
    "kwin-mcp": {
      "command": "uvx",
      "args": ["kwin-mcp"]
    }
  }
}
```

**4. Use it**

Ask Claude Code to launch and interact with any GUI application:

```
Start a KWin session, launch kcalc, and press the buttons to calculate 2 + 3.
```

Claude Code will autonomously start an isolated session, launch the app, read the accessibility tree to find buttons, click them, and take a screenshot to verify the result.

## Configuration

### Recommended: install as a plugin

The fastest way to wire kwin-mcp into your editor is to install one of the bundled plugins. Each plugin auto-registers the MCP server **and** ships the `kwin-desktop-automation` skill, which teaches the agent which tool to call when (session-mode selection, the observe → act → verify loop, US-QWERTY vs Unicode typing, AT-SPI2 surface-local coordinates, and other platform pitfalls).

**Claude Code** — install the plugin from the marketplace:

```text
/plugin marketplace add isac322/kwin-mcp
/plugin install kwin-mcp@kwin-mcp
```

**OpenCode** — add the npm plugin to your `opencode.json`:

```jsonc
{
  "$schema": "https://opencode.ai/config.json",
  "plugin": ["@isac322/kwin-mcp-opencode"]
}
```

For the full integration guide (manual fallback, customising the skill, troubleshooting), see [docs/ai-agent-integration.md](docs/ai-agent-integration.md).

### Claude Code

Add to your project's `.mcp.json`:

```json
{
  "mcpServers": {
    "kwin-mcp": {
      "command": "uvx",
      "args": ["kwin-mcp"]
    }
  }
}
```

Or if installed globally:

```json
{
  "mcpServers": {
    "kwin-mcp": {
      "command": "kwin-mcp"
    }
  }
}
```

### Claude Desktop

Add to your `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "kwin-mcp": {
      "command": "uvx",
      "args": ["kwin-mcp"]
    }
  }
}
```

### Running Directly

```bash
# As an installed script
kwin-mcp

# As a Python module
python -m kwin_mcp

# Print the server's usage and options, then exit (does not start the server)
kwin-mcp --help

# Interactive CLI (REPL for rapid testing)
kwin-mcp-cli

# Live session mode (default to real desktop instead of virtual)
kwin-mcp --default-live-session
kwin-mcp-cli --default-live-session

# Attach captured screenshots to tool results as MCP image content
kwin-mcp --screenshot-images

# Downscale every screenshot so its longer side is at most 2576 pixels
kwin-mcp --screenshot-images --screenshot-max-edge 2576
```

The server accepts only `--default-live-session`, `--screenshot-images`, `--screenshot-max-edge N`, and `-h`/`--help`. `-h`/`--help` anywhere on the command line prints the usage and exits 0, whatever else is present. An unknown option, an abbreviation such as `--default`, a positional argument, a `--screenshot-max-edge` value that is not ASCII digits, or a repeated `--screenshot-max-edge` is a usage error: the server prints the usage and `kwin-mcp: error: ...` to stderr and exits with status 2 without starting.

### Screenshot Images in Tool Results (`--screenshot-images`)

By default, `screenshot` and the `screenshot_after_ms` frame captures of action tools return only the PNG file paths, so the agent must read the files to look at them. Start the server with `--screenshot-images` to also attach each captured PNG to the tool result as MCP `image` content — the single `screenshot` frame and every `screenshot_after_ms` burst frame, in capture order. The text of the result and its `{"result": ...}` structured content are unchanged; image blocks are only appended, and only for tools that actually captured frames.

Claude Code:

```bash
claude mcp add kwin-mcp -- uvx kwin-mcp --screenshot-images
```

Any JSON MCP config takes the flag as another argument:

```json
{
  "mcpServers": {
    "kwin-mcp": {
      "command": "uvx",
      "args": ["kwin-mcp", "--screenshot-images"]
    }
  }
}
```

The flag is off by default because every attached frame is sent to the model and costs context tokens, and a `screenshot_after_ms` burst attaches all of its frames — there is no cap. In a live session (`session_connect` or `--default-live-session`) the images are your real desktop's pixels, so enabling the flag sends them to the model provider.

### Screenshot Size (`--screenshot-max-edge`, `region`, `max_edge`)

A screenshot of a large desktop can be too big for a model. A 7680x1440 workspace is an 11-megapixel PNG of several megabytes. A model provider downscales an oversized image before the model sees it, and the model's pixel coordinates then refer to the resized image, which the `Coordinate space` line does not describe. kwin-mcp can bound and crop the image itself, so the line stays exact:

- `--screenshot-max-edge N` downscales every saved screenshot and `screenshot_after_ms` frame so its longer side is at most `N` pixels, keeping the aspect ratio. `0`, the default, keeps full resolution. The bound must keep the image within the provider's limits, or the provider resizes it again and the formula below no longer matches the model's coordinates. According to [Anthropic's vision documentation](https://platform.claude.com/docs/en/build-with-claude/vision#evaluate-image-size) (checked 2026-10-10), Claude 4.7 and later models take at most 2576 pixels on the long edge and 4784 visual tokens of 28x28 pixels; earlier models take 1568 pixels and 1568 tokens. 2576 suits a wide desktop such as 7680x1440 (2576x483); a 16:9 image fits at 2576x1449 on the newer models and 1456x819 on earlier ones, and a squarer image or crop needs a smaller bound. `N` must be ASCII digits, and the flag may be given only once; anything else is a usage error (exit status 2) before the server starts.
- `screenshot`'s `max_edge` overrides the flag for one call; `max_edge=0` returns full resolution.
- `screenshot`'s `region` (`[x, y, width, height]` in global logical coordinates) crops the capture to that rectangle. The part outside the captured workspace is dropped, and the `Coordinate space` line reports the crop's origin and size. A region that misses the workspace entirely is an error.

When an image was downscaled, its `Coordinate space` line keeps the logical origin and size and adds the image's pixel size and the mapping from the saved image's pixels: each image pixel maps to the logical point at its source pixel's center, for example `image 2576x483 downscaled: pixel (px, py) shows (0 + (px + 0.5) * 7680 / 2576 - 0.5, 0 + (py + 0.5) * 1440 / 483 - 0.5)`. Small text is unreadable in a downscaled overview of a wide desktop, so take the overview first, then a `region` around the area of interest at full resolution.

## Available Tools

### Session Management (3 tools)

| Tool | Parameters | Description |
|------|-----------|-------------|
| `session_start` | `app_command?` `str`, `screen_width?` `int` (1920), `screen_height?` `int` (1080), `enable_clipboard?` `bool` (false), `keep_screenshots?` `bool` (false), `isolate_home?` `bool` (false), `keep_home?` `bool` (false), `env?` `dict` | Start an isolated KWin Wayland session, optionally launching an app. `screen_width` and `screen_height` must be positive integers. Set `enable_clipboard=true` to enable clipboard tools (requires `wl-clipboard`). Set `keep_screenshots=true` to keep the screenshot directory after `session_stop`: a separate `/tmp/kwin-mcp-screenshots-*` directory holding screenshots and launched apps' logs, never inside the isolated home, so `keep_home` does not affect it. Set `isolate_home=true` to create a temporary HOME with isolated XDG directories (config, data, cache, state), so apps use fresh settings instead of the host user's. Set `keep_home=true` to preserve the isolated home directory after `session_stop`. Pass extra environment variables via `env`. Startup is deadline-bounded: a failed handshake raises `RuntimeError` with the captured session stderr and stray stdout. Startup is atomic: if any step fails, the partly started session is stopped and the original error is raised, so no session is left behind. |
| `session_connect` | `dbus_address?` `str`, `wayland_display?` `str`, `keep_screenshots?` `bool` (false), `enable_accessibility?` `bool` (false) | Connect to an existing KWin session (real desktop or container). Defaults to `$DBUS_SESSION_BUS_ADDRESS` and `$WAYLAND_DISPLAY`. Clipboard is always enabled. `session_stop` only disconnects without killing KWin or pre-existing apps. The result's `Accessibility:` line reports the desktop's `org.a11y.Status.IsEnabled` switch; `enable_accessibility=true` turns it on until `session_stop` (see [Accessibility Tree](#accessibility-tree)). |
| `session_stop` | _(none)_ | Stop the session and clean up. For virtual sessions: signals the whole session process group (`SIGTERM`, then `SIGKILL` for members that remain), so descendants still stop even if the session leader already exited. For live sessions: disconnects without killing KWin or pre-existing apps. |

### Observation (3 tools)

| Tool | Parameters | Description |
|------|-----------|-------------|
| `screenshot` | `include_cursor?` `bool` (false), `region?` `list[int]`, `max_edge?` `int` | Capture the whole workspace, or the `[x, y, width, height]` `region` of it, as a PNG. Returns the file path and a `Coordinate space` line with the image's logical origin, size, capture backend, and coverage. Image pixel `(px, py)` is the global logical point `(origin_x + px, origin_y + py)`, the same space `mouse_click` and `touch_tap` take. `max_edge` downscales so the longer side is at most that many pixels (`0` = full resolution; default from `--screenshot-max-edge`); a downscaled image's line adds its pixel size and the pixel-to-logical formula. See [Screenshot Size](#screenshot-size---screenshot-max-edge-region-max_edge). |
| `accessibility_tree` | `app_name?` `str`, `max_depth?` `int` (15), `role?` `str` | Get the AT-SPI2 widget tree with roles, names, states, coordinates, the text content of editors and entries (`text='...'`, capped at 200 characters), and scrollbar/slider positions (`value=current/max`). Use `role` to filter to specific element types (e.g. `"button"`, `"check box"`). Non-matching elements are hidden but their children are still traversed. |
| `find_ui_elements` | `query` `str`, `app_name?` `str`, `states?` `list[str]` | Search for UI elements by name, role, or description (case-insensitive); matches report their text content and scrollbar/slider value when they have one. Optionally filter by AT-SPI2 states (e.g. `["focused"]`, `["active", "visible"]`). `query` can be empty when filtering by states only. |

### Accessibility Actions (1 tool)

| Tool | Parameters | Description |
|------|-----------|-------------|
| `invoke_ui_action` | `query` `str`, `action_name` `str`, `app_name?` `str`, `exact_name?` `bool`, `fallback_action_names?` `list[str]`, `spoken_name?` `bool` | Invoke a native AT-SPI action on exactly one matching element without pointer coordinates. `query` matches element name, role, or description case-insensitively; `action_name` must equal an exposed action name case-insensitively (for example `Press`). With `exact_name=true` the query must equal the element's name after case-folding and collapsing whitespace (punctuation counts), and role and description are not matched. `spoken_name=true` is a deterministic name-matching mode for dictated labels, not fuzzy matching: element names only, case ignored, whitespace normalised, a trailing run of `. , ! ? ; : …` tolerated, all other punctuation significant, and a multi-word query may match the same words joined into one label (`save as` → `SaveAs`) but a one-word query is never split; labels that differ only in these ways are refused as ambiguous, and it cannot be combined with `exact_name`. `fallback_action_names` (for example `["click"]`) lists further actions an element may expose instead of `action_name`; `action_name` is preferred first, then `fallback_action_names` in order. With any of these options each element counts once however many of these actions it exposes, so matching elements in different apps stay ambiguous; without them the original per-action-index counting applies. `app_name` optionally scopes the search. Zero matches do nothing and ambiguous matches fail closed. Discovery fails closed too: if any element or subtree in the searched scope cannot be read, or the tree is deeper than 64 levels, nothing is invoked and the error names the application; set `app_name` to search only the target application. If the application does not confirm a dispatched action, the result says it was sent but not confirmed: the action may have run, so check the UI before retrying. A request that never reached the AT-SPI worker is a plain tool error without a may-have-run warning, because nothing ran. State-changing actions are never automatically retried. |

### Mouse Input (6 tools)

| Tool | Parameters | Description |
|------|-----------|-------------|
| `mouse_click` | `x` `int`, `y` `int`, `button?` `str` ("left"), `double?` `bool`, `triple?` `bool`, `modifiers?` `list[str]`, `hold_ms?` `int` (0), `screenshot_after_ms?` `list[int]` | Click at coordinates. Supports left/right/middle, single/double/triple click, modifier keys (e.g. `["ctrl", "shift"]`; an unknown modifier is an error), and long-press via `hold_ms`. |
| `mouse_move` | `x` `int`, `y` `int`, `screenshot_after_ms?` `list[int]` | Move the cursor (hover) to coordinates without clicking |
| `mouse_scroll` | `x` `int`, `y` `int`, `delta` `int`, `horizontal?` `bool`, `discrete?` `bool`, `steps?` `int` (1) | Scroll at coordinates. `delta` positive = down/right, negative = up/left. Use `discrete=true` for wheel ticks, `steps` to split into smooth increments. |
| `mouse_drag` | `from_x` `int`, `from_y` `int`, `to_x` `int`, `to_y` `int`, `button?` `str` ("left"), `modifiers?` `list[str]`, `waypoints?` `list[[x,y,dwell_ms]]`, `screenshot_after_ms?` `list[int]` | Drag from one point to another with smooth interpolation. Supports custom `waypoints` for complex drag paths; each waypoint must be exactly `[x, y, dwell_ms]`. An unknown modifier or a malformed waypoint is an error, and nothing is sent. |
| `mouse_button_down` | `x` `int`, `y` `int`, `button?` `str` ("left") | Press a mouse button at coordinates without releasing. Use with `mouse_button_up` for manual drag control. |
| `mouse_button_up` | `x` `int`, `y` `int`, `button?` `str` ("left") | Release a previously pressed mouse button at coordinates |

### Keyboard Input (5 tools)

| Tool | Parameters | Description |
|------|-----------|-------------|
| `keyboard_type` | `text` `str`, `screenshot_after_ms?` `list[int]` | Type a string of text character by character (US QWERTY layout: letters, digits, punctuation, space, tab, newline). Text containing any other character (non-ASCII, `\r`, other control characters) is rejected before anything is typed; use `keyboard_type_unicode` for it. |
| `keyboard_type_unicode` | `text` `str`, `screenshot_after_ms?` `list[int]` | Type arbitrary Unicode text (Korean, CJK, etc.) via `wtype`, or, when `wtype` is unavailable or unsupported by the session, a temporary clipboard paste. The paste chord follows the focused window: Ctrl+Shift+V in terminals that paste on it (Konsole, GNOME Terminal, kitty, foot, and others), Ctrl+V in other apps. The previous clipboard content, in all its formats, is restored after the paste, and Klipper keeps the typed text out of its history. Fails as a tool error if the clipboard cannot be taken, nothing reads the text after the paste chord, or the restore fails, and without pressing anything in recognized terminals that have no clipboard paste chord (see [Limitations](#limitations)). Text containing NUL characters is rejected. Does not need `enable_clipboard`. |
| `keyboard_key` | `key` `str`, `screenshot_after_ms?` `list[int]` | Press a key or key combination (e.g., `Return`, `ctrl+c`, `alt+F4`, `shift+Tab`) |
| `keyboard_key_down` | `key` `str` | Press and hold a key without releasing. Useful for holding modifiers across multiple actions (e.g., hold Ctrl while clicking items). |
| `keyboard_key_up` | `key` `str` | Release a previously held key |

### Touch Input (4 tools)

| Tool | Parameters | Description |
|------|-----------|-------------|
| `touch_tap` | `x` `int`, `y` `int`, `hold_ms?` `int` (0), `screenshot_after_ms?` `list[int]` | Tap at coordinates. Use `hold_ms` for long-press gestures. |
| `touch_swipe` | `from_x` `int`, `from_y` `int`, `to_x` `int`, `to_y` `int`, `duration_ms?` `int` (300), `screenshot_after_ms?` `list[int]` | Swipe from one point to another with configurable duration |
| `touch_pinch` | `center_x` `int`, `center_y` `int`, `start_distance` `int`, `end_distance` `int`, `duration_ms?` `int` (500), `screenshot_after_ms?` `list[int]` | Two-finger pinch gesture. `end_distance < start_distance` = pinch in, `end_distance > start_distance` = pinch out. |
| `touch_multi_swipe` | `from_x` `int`, `from_y` `int`, `to_x` `int`, `to_y` `int`, `fingers?` `int` (3), `duration_ms?` `int` (300), `screenshot_after_ms?` `list[int]` | Multi-finger swipe gesture for system gestures like workspace switching. `fingers` must be at least 1 (default 3); KWin consumes swipes with 3 or more fingers as global gestures (see [Limitations](#limitations)). |

### Clipboard (2 tools)

| Tool | Parameters | Description |
|------|-----------|-------------|
| `clipboard_get` | _(none)_ | Read the current clipboard text content. Requires `enable_clipboard=true` in `session_start` and `wl-clipboard` installed. A disabled clipboard, a missing `wl-paste`, or an empty or unreadable clipboard is a tool error. |
| `clipboard_set` | `text` `str` | Set the clipboard text content; text of any size, including NUL characters, is accepted. Replaces the current selection, so it is annotated destructive. Same requirements as `clipboard_get`; failures are tool errors. The confirmation echoes text of up to 200 characters; longer text is reported as its length. |

### Window Management (9 tools)

| Tool | Parameters | Description |
|------|-----------|-------------|
| `launch_app` | `command` `str`, `env?` `dict` | Launch an application inside the running session. Returns PID and log path. An empty command or one `shlex` cannot split (e.g. an unclosed quote) is an error. |
| `list_windows` | _(none)_ | List all accessible application windows with per-window titles and active/focused state markers via AT-SPI2. Titles are raw AT-SPI names: they may be empty (`(untitled)`) or differ from the KWin captions `window_geometry` reports. |
| `focus_window` | `app_name?` `str`, `window_id?` `str` | Activate and raise a window via KWin scripting. One of `app_name` or `window_id` is required. Use `window_id` for an exact window from `window_geometry` (it takes precedence when both are given); otherwise `app_name` uses a case-insensitive application/title match. No selector, no matching window, or a failed activation is a tool error. |
| `window_geometry` | `app_name?` `str`, `window_id?` `str` | Report each window's KWin id, an `[active]` marker, and frame and client rectangles in **global screen coordinates** via KWin scripting — the same space `find_ui_elements` / `accessibility_tree` report and `mouse_click` / `touch_tap` take. `window_id` filters to one window by exact id. |
| `window_set_geometry` | `window_id` `str`, `x?` `int`, `y?` `int`, `width?` `int`, `height?` `int` | Move and/or resize exactly one window by its KWin id. Any subset of x/y/width/height may be supplied; at least one field is required. Every field must be within the signed 32-bit range, and width/height must be positive and at most the KWin workspace width/height (start the session with a larger screen for bigger windows). The `before:` line is the frame KWin reported immediately before the request. The resulting frame geometry is observed again through KWin before it is reported; fields the client has not applied yet are named, and a window that went away before the readback is reported as `observed: window no longer exists`. Invalid arguments, an unknown id, and a failed request are tool errors. |
| `virtual_desktops` | _(none)_ | List KWin virtual desktops in order, each with its stable id, name and a current marker. Ids stay valid while the desktop exists, unlike positions. A failed KWin query is a tool error. |
| `window_set_desktop` | `window_id` `str`, `desktop_id` `str` | Assign the window with the exact KWin id to only the desktop with the exact id from `virtual_desktops`, including windows previously on all desktops. Does not explicitly select the current desktop or activate the window. Reports previous and observed membership, selected-window-only `applied`, and point-in-time current desktops before/after. Missing or unknown ids, refused requests, and missing request results are tool errors; failed readback is `observation_error` because the window may have moved. See routing boundaries below. |
| `active_window` | _(none)_ | Report the window KWin currently treats as active (id, frame, client), e.g. to confirm `focus_window` |
| `window_close` | `window_id` `str` | Ask exactly one window, addressed by the id from `window_geometry`, to close (like its titlebar button). Disabled in live sessions to protect unsaved work. A missing or unknown id, a window that cannot be closed, a failed request, and the live-session refusal are tool errors; a sent request reports `Close requested`, since the app may keep the window open. |

> **Virtual desktop routing boundaries:** Preflight refuses requests that would move related windows (transients or modal main windows) KWin scripting can enumerate at request time, and requests while an X11 group transient exists. Linked but unpainted or unlisted children may follow their parent; when they first appear, KWin may switch the current desktop. `applied` verifies only the selected window's observed membership. The reported `current_desktop_id_before` and `current_desktop_id_after` are point-in-time observations, not future desktop or focus guarantees.

### UI Polling (1 tool)

| Tool | Parameters | Description |
|------|-----------|-------------|
| `wait_for_element` | `query` `str`, `app_name?` `str`, `timeout_ms?` `int` (5000), `poll_interval_ms?` `int` (200), `expected_states?` `list[str]` | Poll the accessibility tree until an element matching the query and/or states appears or timeout expires. Use `expected_states` to wait for state changes (e.g. `["active"]`, `["checked"]`). `query` can be empty when waiting for state changes only. |

### Advanced (3 tools)

| Tool | Parameters | Description |
|------|-----------|-------------|
| `dbus_call` | `service` `str`, `path` `str`, `interface` `str`, `method` `str`, `args?` `list[str \| dict]` | Call any D-Bus method in the isolated session. Useful for controlling KWin scripting, app-specific D-Bus APIs, and system services. Each argument is a dbus-send string (`"int32:42"`, `"dict:string:variant:k,string:v"`) or a typed-JSON object (`{"type": "int32", "value": 42}`). Malformed values, and arguments whose types or count fit none of the method's declared signatures, are tool errors (`D-Bus call failed: ...`) and nothing is sent; a D-Bus error reply from the remote side is a tool error too (`D-Bus error: <name>: <message>`). Replies are empty for void methods, the bare value for one basic value (`GetId` returns just the ID), and JSON otherwise. |
| `read_app_log` | `pid` `int`, `last_n_lines?` `int` (50) | Read stdout/stderr output of a launched app by PID. `last_n_lines` must be `0` (all output) or a positive integer. |
| `wayland_info` | `filter_protocol?` `str` | List Wayland protocols available in the session. Useful for verifying protocol access (e.g., `plasma_window_management`). |

> **Frame capture:** Many action tools accept an optional `screenshot_after_ms` parameter (e.g., `[0, 50, 100, 200, 500]`) that captures screenshots at specified delays (in milliseconds) after the action completes. This is useful for observing transient UI states like hover effects, click animations, and menu transitions without extra MCP round-trips. Frame capture uses the fast KWin ScreenShot2 D-Bus interface (~30-70ms per frame). Each frame is followed by its own `Coordinate space` line in the same format as `screenshot`. A frame whose mapping cannot be proven — a topology change or a failed observation during the capture — still writes its PNG but reports `Coordinate space: unavailable (reason)`; that PNG holds the backend's raw unnormalized pixels, so only its timing observation is meaningful.

> **Structured output:** `window_geometry`, `find_ui_elements`, `list_windows`, `accessibility_tree`, `virtual_desktops`, and `window_set_desktop` also return typed `structuredContent` described by their MCP `outputSchema`: window ids, the active flag, and frame/client rectangles; element role, name, states, and screen rectangle (or the reason it is unavailable); applications with their windows; desktop ids, names, and the current flag; and a desktop assignment's previous, observed, and current desktops. MCP clients that support structured tool output (spec 2025-06-18 and later) can read these fields directly instead of parsing text. The text result is unchanged; the other tools keep the `{"result": string}` output schema. There is no `error` field: a failed KWin query is a tool error.

## How It Works

```
Claude Code / AI Agent
  |
  |  MCP (stdio)
  v
kwin-mcp server  (37 tools)       kwin-mcp-cli (interactive REPL)
  |                                  |
  +--- both delegate to AutomationEngine (core.py) ---+
  |
  |-- session_start (virtual) ---> dbus-run-session
  |                                 |-- at-spi-bus-launcher (D-Bus activated)
  |                                 +-- kwin_wayland --virtual
  |                                       +-- [your app]
  |
  |-- session_connect (live) ----> existing KWin (real desktop / container)
  |
  |-- screenshot ---------------> KWin ScreenShot2 D-Bus (spectacle fallback)
  |
  |-- accessibility_tree -------> AT-SPI2 (via PyGObject)
  |-- find_ui_elements ---------> AT-SPI2 (via PyGObject)
  |-- wait_for_element ----------> AT-SPI2 (polling)
  |-- invoke_ui_action ----------> AT-SPI2 Action interface (via PyGObject)
  |
  |-- mouse_* ------------------> KWin EIS D-Bus --> libei
  |-- keyboard_* ---------------> KWin EIS D-Bus --> libei
  |-- touch_* ------------------> KWin EIS D-Bus --> libei
  |    +-- screenshot_after_ms -> KWin ScreenShot2 D-Bus (fast frame capture)
  |
  |-- keyboard_type_unicode ----> wtype, or temporary Wayland data-control clipboard owner + paste chord for the focused app
  |-- clipboard_* --------------> wl-copy / wl-paste (wl-clipboard)
  |
  |-- launch_app / list_windows
  |                                |-- subprocess spawn
  |                                +-- AT-SPI2 (via PyGObject)
  |
  |-- focus_window / window_geometry / active_window / window_set_geometry
  |    |-- virtual_desktops / window_set_desktop / window_close
  |                                +-- KWin scripting (loadScript + D-Bus reply)
  |
  |-- dbus_call -----------------> dbus-python (in-process, introspected signature)
  |-- read_app_log --------------> log file read
  +-- wayland_info --------------> wayland-info
```

### Tool Annotations and Progress Notifications

Every tool publishes MCP tool annotations in `tools/list`, so clients can decide which calls need user confirmation. Observation tools such as `screenshot`, `accessibility_tree`, `find_ui_elements`, `wait_for_element`, `list_windows`, `virtual_desktops`, and `clipboard_get` are marked read-only. `window_close`, `window_set_geometry`, `window_set_desktop`, `invoke_ui_action`, `clipboard_set`, `session_stop`, `dbus_call`, and the mouse, keyboard, and touch input tools other than `mouse_move` are marked destructive: injected input or an invoked action can trigger any action in the app, such as deleting a file or sending a message, and `clipboard_set` replaces the current selection. `session_start` and `launch_app` are marked destructive too: both run arbitrary commands. `mouse_move`, `focus_window`, and `session_connect` change state but are not marked destructive. The annotations are hints for the client; kwin-mcp does not enforce them.

A call that does not do what was asked comes back as an MCP tool error (`isError: true`, text `Error executing tool <name>: <message>`), and `kwin-mcp-cli` prints it as one `Error: <message>` line on stderr. This covers invalid arguments, refusals and unmet preconditions (a session already running, the clipboard disabled, `window_close` in a live session), missing or ambiguous targets, and failed mechanisms (a KWin query, `wl-copy`/`wl-paste`, `wayland-info`, D-Bus, or a missing helper binary). Answers stay normal results: an empty query (`No windows found`, `No active window.`, `No elements found`), a `wait_for_element` timeout, `session_stop` with no session, and dispatched actions whose outcome cannot be confirmed (`window_set_geometry` fields `not yet applied`, a `window_set_desktop` readback with `applied` false, `window_close` `Close requested`, an unconfirmed `invoke_ui_action`), which say so in their text.

Long-running tools send MCP progress notifications when the client includes a `progressToken` in the request: `session_start` and `session_connect` report their startup steps, `wait_for_element` reports polling (never past its total), `screenshot_after_ms` bursts report captured frames, and long `mouse_click`/`touch_tap` holds, touch gestures, `mouse_drag`, and `keyboard_type` report their progress; `keyboard_type` always ends with its final `Typed N/N characters` step. Clients that send no token receive no notifications, and tool results are the same either way.

### Triple Isolation (+ Optional Home Isolation)

kwin-mcp provides three layers of isolation from the host desktop:

1. **D-Bus isolation** -- `dbus-run-session` creates a private session bus. The isolated session's services (KWin, AT-SPI2, portals) are invisible to the host.
2. **Display isolation** -- `kwin_wayland --virtual` creates its own Wayland compositor with a virtual framebuffer. No windows appear on the host display.
3. **Input isolation** -- Input events are injected through KWin's EIS interface into the isolated compositor only. The host desktop receives no input from kwin-mcp.
4. **Home directory isolation** (optional) -- When `isolate_home=true` is set in `session_start`, a temporary HOME directory is created with isolated XDG directories (`XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME`, `XDG_STATE_HOME`). Apps that follow the XDG base directories read and write these instead of host user settings (e.g. `~/.config/kdeglobals`), improving test reproducibility. `XDG_RUNTIME_DIR` is intentionally not isolated because the Wayland socket resides there.

#### Isolation scope

These layers separate the session's bus, display, and input from the host desktop. They do not confine the processes: launched apps run as your user with your filesystem permissions and network access, and can still reach the original home directory by absolute path when `isolate_home=true`. Run untrusted applications in a container or VM rather than relying on a virtual session.

### Automatic Session Cleanup at Exit

When the MCP server process ends — on stdio EOF, `SIGTERM`, `SIGHUP`, or `SIGINT` — it stops the active session exactly as `session_stop` would, so a session the client never stopped does not outlive the server. A virtual session's KWin compositor, its `dbus-run-session` wrapper bus, and every app launched through `session_start` or `launch_app` are terminated; in a live `session_connect` session the user's KWin and pre-existing apps are never signalled — only apps kwin-mcp launched are stopped. The exit code follows convention: 0 on a clean EOF, 1 on an unexpected server failure, and 128+signum on a signal (143 for `SIGTERM`, 129 for `SIGHUP`, 130 for `SIGINT`).

The final stop runs on the same single thread as tool calls, serialized with any in-flight tool. If a running tool still holds that thread after a 2 s drain, the server instead terminates the process groups it spawned directly and removes the temporary directories `session_stop` would remove for the session's `keep_home`/`keep_screenshots` retention settings. `kwin-mcp-cli` stops the session on `SIGTERM`/`SIGHUP` through the same path as Ctrl-C, and on exit also terminates any owned process group that was never attached to a session.

Known limits: `SIGKILL` runs no cleanup; a descendant that leaves its launched process group (a double-fork or `setsid` daemon) escapes; and a process group whose leader was already reaped is left alone, because ownership can no longer be proven.

### Input Injection

Mouse, keyboard, and touch events are injected through KWin's private `org.kde.KWin.EIS.RemoteDesktop` D-Bus interface. This returns a `libei` file descriptor that allows low-level input emulation without requiring the XDG RemoteDesktop portal (which would show a user authorization dialog). The connection uses:

- **Absolute pointer positioning** in KWin's global logical coordinates, the space `window_geometry`, element rectangles, and screenshot pixels (after adding the screenshot origin) share
- **evdev keycodes** with full US QWERTY mapping for keyboard input
- **Smooth drag interpolation** (10+ intermediate steps) for realistic drag operations
- **EIS touch emulation** for multi-touch gestures (tap, swipe, pinch, multi-finger swipe)

### Screenshot Capture

The normal Wayland capture path tries KWin's `org.kde.KWin.ScreenShot2` D-Bus interface first and uses the `spectacle` CLI as a fallback. Both capture the whole workspace across all outputs. Action tools with `screenshot_after_ms` use the same path for frame bursts, and Pillow converts ScreenShot2's raw pipe frames (RGB32, ARGB32, or RGBX8888 depending on the KWin version) to PNG. The Docker visual QA suite also has an explicit test-only X11 backend: when `KWIN_MCP_X11_SCREENSHOT=1` is set for a server connected to the nested KWin/Xvfb fixture, captures use `scrot`. Normal virtual and live Wayland sessions do not opt into this backend.

Each Spectacle capture has a deadline derived from the image Spectacle must produce, the workspace canvas at the output scale (on mixed-scale layouts, every output is upscaled to the next whole scale above the largest): 15 seconds plus 1 second per canvas megapixel, capped at 120 seconds. A 1920x1080 output gets 18 seconds; a mixed-scale 1.45 + 1.0 layout (a 6490x2160 canvas) gets 30 seconds. A capture that does not finish in time fails with `spectacle timed out after <N>s`.

Saved PNGs are normalized to KWin's global logical coordinate space, so a screenshot taken on a fractionally scaled output has one image pixel per logical pixel. The result states the mapping, for example on two outputs where a 1280x1024 screen sits left of a 1920x1080 screen:

```text
Screenshot saved: <path> (<size> KB)
Coordinate space: logical; origin (-1280, 0); size 3200x1080; backend screenshot2; coverage full; topology observed stable before/after capture
```

`origin` is the top-left of KWin's virtual screen geometry. It is negative when an output sits left of or above `(0, 0)`. To click image pixel `(px, py)`, pass `x = origin_x + px` and `y = origin_y + py` to `mouse_click`; no scale conversion is needed. On a single output at scale 1, the origin is `(0, 0)` and the size equals the screen size.

Backends deliver different pixel spaces, and kwin-mcp normalizes each one:

- **ScreenShot2** `CaptureWorkspace` already renders logical pixels for the whole virtual screen, including outputs at negative positions.
- **Spectacle** captures device pixels, which kwin-mcp rescales to logical pixels. When all screens share one scale, Spectacle's composite clips outputs at negative logical positions. kwin-mcp cannot recreate those pixels: it leaves them transparent and reports `coverage partial` with the captured regions. With mixed scales, Spectacle releases before 6.7.90 (including all Gear-numbered releases such as 24.12) place each screen through OpenCV and abort (or distort the screen) when one lies outside their canvas, for example at a negative position. kwin-mcp detects that layout from the topology and does not start Spectacle; the screenshot fails with an error naming the output instead.
- **X11/scrot** (test-only) captures the X root, where each KWin output is an X window of `logical size * scale` pixels. kwin-mcp locates those windows with `xwininfo` and rescales each one to logical pixels.

kwin-mcp reads the output topology through KWin scripting immediately before and after each capture and checks the captured image against it, retrying once when the layout changed mid-capture. If the mapping still cannot be proven, `screenshot` fails with an error containing `coordinate mapping cannot be proven` rather than returning guessed coordinates; a burst frame keeps its raw PNG and reports `Coordinate space: unavailable (reason)` instead. Observed stability is not an atomicity guarantee: a topology change that reverted between the two observations can go unnoticed.

### Accessibility Tree

The AT-SPI2 accessibility bus within the isolated session is queried via PyGObject (`gi.repository.Atspi`). This provides a structured tree of all UI widgets with their roles (button, text field, menu item, etc.), names, states (focused, enabled, visible, etc.), screen coordinates, and available actions (click, toggle, etc.).

The virtual session's accessibility bus normally starts through D-Bus activation of the distro's `org.a11y.Bus` service. Fedora's SELinux policy can deny that activation from a private session bus ([fedora-selinux/selinux-policy#3385](https://github.com/fedora-selinux/selinux-policy/issues/3385)). Direct startup is disabled by default: only the exact value `1` in the server's `KWIN_MCP_ATSPI_DIRECT_START` environment variable enables it, not an application's `env` argument. Neither the server nor a healthy activation path performs direct-start service-file lookup. Only after activation fails does the private session wrapper start an owned child to look up and execute the launcher, then the registry.

The child reads the named AT-SPI service files from operator-trusted `XDG_DATA_DIRS` roots, defaulting to `/usr/local/share:/usr/share`; those roots need not mirror every root D-Bus activation searches. Before executing the launcher, it validates both service commands. The registry child reads and validates its own file again when it starts; if that file has become invalid, direct startup rolls back. `Exec=` parsing follows dbus-daemon's argument splitting. Acquisition retains the 128 KiB content limit and file-descriptor checks: missing or nonregular candidates and existing invalid-path/link cases remain skippable, while malformed, unreadable, or oversized regular files and inaccessible metadata, paths, or roots stop lookup.

Failed direct startup reports a `Warning:` line and lets KWin continue. Rollback targets the wrapper's owned Bash jobs, with a shared 3-second TERM grace followed by a shared 2-second KILL grace for the launcher and registry, not a separate wait for each child. Running lookup in those children bounds the caller's wait even when regular-file metadata or reads stall on NFS/FUSE; it does not guarantee that a kernel process stuck in uninterruptible I/O disappears immediately. Launcher descendants may remain in the owned session group until `session_stop`; programs that escape that group are not sandboxed by this mechanism. Normal D-Bus activation and the existing 60-second session-start watchdog are unchanged: a stall in normal activation itself still relies on that watchdog.

The private helper uses the server's existing `sys.executable` with `-I -S` to run the installed, standard-library-only `dbus_service_file.py`, then `os.execv` on the parsed executable. It is internal startup machinery, not another installation step or MCP tool. This avoids implicit `PATH` lookup, shell builtins, function evaluation, and `ENOEXEC` shell fallback; explicitly configured `/bin/sh -c` commands and legitimate `#!` scripts remain supported. Direct processes keep the same user, private bus, session process group, environment, and inherited working directory; relative executables resolve against the server's working directory, not an isolated directory. They start from the server process's SELinux domain rather than through normal D-Bus service activation, which can mean less confinement. The opt-in does not change SELinux policy or provide a general sandbox. Successful direct startup also reports a `Warning:` line. Tests simulate activation denial; they do not verify this path on an SELinux-enforcing Fedora host.

Firefox, like other apps that check it, exposes an accessibility tree only while the session's `org.a11y.Status.IsEnabled` switch is on, and a stock Plasma session leaves it off. Virtual sessions switch it on before any app starts; their accessibility bus is private, so the host desktop is unaffected. A live session (`session_connect`) does not change it by default; the result's `Accessibility:` line says whether it is on. Pass `enable_accessibility=true` to switch it on for the whole desktop until `session_stop`, which switches it back off (unless it was already on before the connection). The restore turns the switch back off whenever this connection turned it on, so a change the user makes to the switch while connected is overwritten at `session_stop`; enabling accessibility before connecting keeps it on. Every switch read and write is bounded to a few seconds, so a stalled desktop bus cannot hold `session_stop` or the server's exit. Firefox follows the change at runtime; an app that reads the switch only at startup must be restarted. The server's exit switches it back off too, including when a tool is still running. Restoring is best-effort: a server killed with `SIGKILL`, or a bus that refuses the call, leaves the switch on. Launch Chromium with `--force-renderer-accessibility` to expose web page content: in Chromium 154 the switch alone gave only empty window frames, and the flag gave the page tree whatever the switch.

## System Requirements

| Requirement | Details |
|-------------|---------|
| **OS** | Linux with KDE Plasma 6 (Wayland session) |
| **Python** | 3.12 or later |
| **KWin** | `kwin_wayland` with `--virtual` flag support (KDE Plasma 6.x) |
| **libei** | EIS input emulation client library. Not a `kwin-wayland` dependency on Debian/Ubuntu; install `libei1` (Arch's `kwin` package depends on it) |
| **libwayland-client** | Used by the `keyboard_type_unicode` clipboard paste; already installed as a KWin dependency |
| **spectacle** | KDE screenshot tool (CLI mode); packaged as `kde-spectacle` on Debian and Ubuntu |
| **AT-SPI2** | `at-spi2-core` for accessibility tree support |
| **PyGObject** | GObject introspection Python bindings (built from source by uv/pip; see [build prerequisites](#build-prerequisites-for-uv-and-pip-installs)) |
| **D-Bus** | `dbus-python` bindings (built from source by uv/pip; needs libdbus development files) |
| **Build tools** | C compiler, `pkg-config`, Python headers, and cairo, GObject Introspection, and libdbus development files |

**Optional dependencies:**

| Package | Required for |
|---------|-------------|
| `wl-clipboard` (`wl-copy`, `wl-paste`) | `clipboard_get` and `clipboard_set` |
| `wtype` | `keyboard_type_unicode` direct typing, tried first; when it is unavailable or unsupported by the session, the built-in clipboard paste is used instead |
| `wayland-utils` (`wayland-info`) | `wayland_info` tool |

### Installing System Dependencies

kwin-mcp needs two groups of system packages: runtime packages (KWin, Spectacle, AT-SPI2) and build prerequisites for the Python packages it installs from PyPI.

#### Build Prerequisites for uv and pip Installs

`uv tool install kwin-mcp` and `uvx kwin-mcp` always use an isolated Python environment, and `pip install kwin-mcp` does too when run inside a virtual environment. An isolated environment cannot see the system `gi` (PyGObject) or `dbus` modules installed by your distribution, so uv or pip builds [PyGObject](https://pypi.org/project/PyGObject/), [pycairo](https://pypi.org/project/pycairo/), and [dbus-python](https://pypi.org/project/dbus-python/) from source. These builds need a C compiler, `pkg-config`, the Python headers, and the cairo, GObject Introspection, and libdbus development files. Without them, installation fails while building those packages.

On Debian 13 (Trixie):

```bash
sudo apt-get install -y --no-install-recommends build-essential pkg-config python3-dev libcairo2-dev libgirepository-2.0-dev libdbus-1-dev
```

For other distributions, follow the "Installing from PyPI with pip" build dependency steps in the [PyGObject Getting Started guide](https://pygobject.gnome.org/getting_started.html), and also install your distribution's libdbus development package (it provides the `dbus-1` pkg-config file that dbus-python needs).

#### Runtime Packages

<details>
<summary><strong>Debian 13 (Trixie)</strong></summary>

Debian packages Spectacle as `kde-spectacle`; there is no `spectacle` package. `gir1.2-atspi-2.0` provides the AT-SPI2 GObject Introspection typelib that PyGObject loads at runtime. `libei1` provides the EIS client library used for input injection; `kwin-wayland` does not depend on it, so it must be installed explicitly. `kwin-common` ships KWin's EIS plugin (without it `session_start` reports `No input backend available`), and `qt6-wayland` lets Qt apps such as KCalc open windows on Wayland. `kwin-wayland` pulls in neither; a full Plasma desktop already has both, but a minimal system or container needs them listed.

```bash
sudo apt-get install -y --no-install-recommends kwin-wayland kwin-common qt6-wayland kde-spectacle libei1 at-spi2-core gir1.2-atspi-2.0

# Optional: for clipboard and Unicode input
sudo apt-get install -y --no-install-recommends wl-clipboard wtype wayland-utils
```

</details>

<details>
<summary><strong>Arch Linux / Manjaro</strong></summary>

```bash
sudo pacman -S kwin spectacle at-spi2-core python-gobject dbus-python-common

# Optional: for clipboard and Unicode input
sudo pacman -S wl-clipboard wtype wayland-utils
```

</details>

<details>
<summary><strong>Fedora (KDE Spin)</strong></summary>

```bash
sudo dnf install kwin-wayland spectacle at-spi2-core python3-gobject dbus-python

# Optional: for clipboard and Unicode input
sudo dnf install wl-clipboard wtype wayland-utils
```

</details>

<details>
<summary><strong>openSUSE (KDE)</strong></summary>

```bash
sudo zypper install kwin6 spectacle at-spi2-core python3-gobject python3-dbus-python

# Optional: for clipboard and Unicode input
sudo zypper install wl-clipboard wtype wayland-utils
```

</details>

<details>
<summary><strong>Kubuntu / KDE Neon (Plasma 6 releases only)</strong></summary>

Use a release that ships KDE Plasma 6 and Python 3.12 or later; older Kubuntu releases with Plasma 5 are not supported. Install the Debian build prerequisites above as well. Ubuntu packages Spectacle as `kde-spectacle`.

```bash
sudo apt install kwin-wayland kwin-common qt6-wayland kde-spectacle libei1 at-spi2-core python3-gi gir1.2-atspi-2.0 python3-dbus

# Optional: for clipboard and Unicode input
sudo apt install wl-clipboard wtype wayland-utils
```

</details>

## Installation

Install the [system and build dependencies](#installing-system-dependencies) before using any method below. The uv installs, pip installs into a virtual environment, and the from-source install build PyGObject, pycairo, and dbus-python from source and fail without them.

> [!NOTE]
> kwin-mcp 0.7.0 on PyPI did not cap its `mcp` dependency, so a fresh install could resolve `mcp` 2.x and the server failed at startup with `ModuleNotFoundError: No module named 'mcp.server.fastmcp'`. Releases 0.8.0–0.9.x cap `mcp<2`, and 0.10.0 and later require `mcp>=2.2.0,<3`; upgrading kwin-mcp fixes the error.

### Using uv (recommended)

```bash
uv tool install kwin-mcp
```

uv also provides `uvx`, which the `.mcp.json` examples above use to run kwin-mcp. If the `kwin-mcp` command is not found after `uv tool install`, run `uv tool update-shell` and restart your shell so uv's tool directory is on `PATH`.

### Using pip

```bash
pip install kwin-mcp
```

### From source

```bash
git clone https://github.com/isac322/kwin-mcp.git
cd kwin-mcp
uv sync
uv run kwin-mcp
```

## Limitations

- **US QWERTY keyboard layout only** -- `keyboard_type` supports US QWERTY only: letters, digits, ASCII punctuation, space, tab, and newline. Text containing any other character (non-ASCII such as Korean, CJK, or accented letters, `\r`, or other control characters) is rejected before anything is typed, with an error naming the unsupported characters; use `keyboard_type_unicode` for it. Virtual sessions pin the compositor keymap to US, so the host's keyboard layout settings do not change what is typed; a live session attached with `session_connect` keeps the desktop's own keymap, so its active layout must be US for `keyboard_type` and `keyboard_key` to produce the expected characters.
- **Unicode typing can paste through the clipboard** -- When `wtype` is unavailable or unsupported by the session, `keyboard_type_unicode` briefly puts the text on the clipboard, then restores the previous selection and keeps serving it until another copy replaces it, even after `session_stop` disconnects a live session. The restore is best-effort, not atomic: Wayland has no compare-and-swap for the selection, so a copy another client makes at the same moment as the restore can be overwritten. Wayland also does not tell the clipboard owner which client read the text, so a clipboard manager that reads it after the paste chord can be taken for the target app, and any client that read it keeps its copy. The `x-kde-passwordManagerHint` marker keeps the text out of Klipper's history, but clipboard managers that ignore the marker can still record it.
- **Unicode typing into terminals is limited** -- Terminals do not paste on Ctrl+V, and one that does not bind it passes it to the program as `^V`, which changes the next key (a Return arrives as a literal CR). `keyboard_type_unicode` therefore checks KWin's active window first and sends Ctrl+Shift+V to terminals known to paste on it (Konsole, Yakuake, GNOME Terminal, GNOME Console, kitty, Alacritty, WezTerm, foot, Ghostty, st, Xfce Terminal, Tilix, Terminator). Window classes recognized as terminals but with no clipboard paste chord (xterm and urxvt, whose Shift+Insert pastes the primary selection) and windows KWin cannot identify get a failure result without any key being pressed; a terminal whose class is not recognized still gets Ctrl+V. Only Konsole is exercised by the test suite; the other chords follow each terminal's documented default bindings, so a remapped paste shortcut breaks delivery there.
- **KDE Plasma 6+ required** -- Older KDE versions or other Wayland compositors (GNOME, Sway) are not supported.
- **AT-SPI2 availability varies** -- Some applications may not fully expose their widget tree via AT-SPI2.
- **Touch input is EIS-emulated** -- Touch events are emulated through KWin's EIS interface, not from a real touchscreen device. Most applications handle emulated touch correctly, but some may behave differently from physical touch.
- **Clipboard requires opt-in** -- Clipboard tools (`clipboard_get`, `clipboard_set`) are disabled by default because `wl-copy` can hang in isolated sessions. Enable with `enable_clipboard=true` in `session_start`, and ensure `wl-clipboard` is installed.
- **QMenu (native context menus) may not appear in AT-SPI2** -- Qt's AT-SPI2 bridge has incomplete support for popup menus on Wayland. Context menus may not be visible in `accessibility_tree` or `find_ui_elements`. Workaround: click by coordinates derived from the parent widget's reported screen rectangle.
- **Screen edge triggers ignore EIS pointer events** -- Auto-hide panels and layer-shell strips do not react when the pointer reaches a screen edge through EIS. Use `dbus_call` to invoke KWin scripting or a keyboard shortcut instead of trying to hover the edge.
- **KWin claims multi-finger touch gestures** -- Three- and four-finger swipes are consumed by the compositor as global gestures and never reach the application; use `fingers=2` when the target is the app itself.
- **A multi-finger swipe that starts on a titlebar can block pointer clicks** -- A swipe with 3 or more fingers whose first (topmost) finger lands on a window's server-side titlebar or decoration can leave KWin withholding pointer button events from that window. The stuck state lasts until another window is activated; refocusing the same window does not clear it. `touch_multi_swipe` puts its first finger at `from_y - (fingers-1)*10`, so keep that point inside the client area.
- **Window titles from AT-SPI2 are raw names** -- `list_windows` reports each window's AT-SPI name, which may be empty (`(untitled)`) or differ from the caption KWin shows; use `window_geometry` for KWin captions and ids.
- **Element coordinates are screen-global, or unavailable** -- `find_ui_elements`, `accessibility_tree` and `wait_for_element` report rectangles in the same global screen coordinates `mouse_click` and `touch_tap` take (`@ screen (x, y, wxh)`). When the element's window cannot be matched to exactly one KWin window — an app that masks its real process id (e.g. a D-Bus proxy), several identical windows of one process, or a window set that changed mid-query — the element reports `@ unavailable (reason)` with no coordinates rather than a position that could click the wrong window.
- **Spectacle fallback can return partial screenshots or refuse a layout** -- When ScreenShot2 is unavailable and Spectacle composites several screens of one scale, Spectacle clips outputs at negative logical positions. Those regions stay transparent, and the `Coordinate space` line reports `coverage partial` with the regions that were captured. When the screens have mixed scales and one lies outside Spectacle's canvas (such as a screen at a negative position), Spectacle before 6.7.90 (including Gear-numbered releases such as 24.12) aborts inside OpenCV, so kwin-mcp reports a capture error without starting it. ScreenShot2 captures the whole workspace in both cases.
- **`screenshot` captures the current virtual desktop's whole workspace only; no single-window capture exists** -- `screenshot` and `screenshot_after_ms` frames use KWin ScreenShot2 `CaptureWorkspace` (with a Spectacle fallback, or the opt-in X11/scrot backend enabled by `KWIN_MCP_X11_SCREENSHOT=1`): a window on another, inactive virtual desktop is not in the image, and no kwin-mcp tool captures a single window by id (#34). `dbus_call` cannot reach `org.kde.KWin.ScreenShot2.CaptureWindow` either, because it cannot pass the unix file descriptor the capture methods require. In a live session, KWin also refuses ScreenShot2 calls with `org.kde.KWin.ScreenShot2.Error.NoAuthorized` unless the caller's executable (`/proc/<pid>/exe`) matches the first token of `Exec=` in a `.desktop` file that declares `X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2`. A user-level file under `~/.local/share/applications` is picked up after `kbuildsycoca6` with no KWin restart (kwin-mcp's own virtual sessions disable this check):

  ```ini
  [Desktop Entry]
  Type=Application
  Name=kwin-mcp ScreenShot2
  Exec=/path/to/copies-venv/bin/python3
  X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2
  ```

  The grant applies to every process running that exact binary. For a Python program the authorized executable is the interpreter, so whitelisting `/usr/bin/python3` authorizes every Python process that runs on it; KWin compares canonical paths, so a default venv's `bin/python3` is a symlink that resolves to the system interpreter and grants it too. Only a venv created with `python -m venv --copies` has its own interpreter binary that can be whitelisted on its own. Even with the whitelist, a window on an inactive desktop stops redrawing, so `CaptureWindow` returns the last frame drawn while it was visible, not its live state; only KWin's screencast path (`zkde_screencast_unstable_v1` `stream_window`) keeps an off-screen window rendering, gated by the same authorization mechanism under `X-KDE-Wayland-Interfaces`. KWin scripts and QML `WindowThumbnail` cannot save a window's pixels.

## End-to-End Testing

The Docker suite collects every test under `tests/e2e` against the packaged application, not an editable source checkout. `docker/e2e.Dockerfile` builds a wheel and installs it into `/opt/kwin-mcp-venv` with standard `Requires-Dist` resolution: PyGObject, pycairo, and dbus-python compile from source in a builder-only stage, while `mcp`, Pillow, and the remaining dependencies resolve fresh from PyPI within the declared ranges. The suite runs both `AutomationEngine` tests and the installed `kwin-mcp` console entry point. The MCP tests initialize a real client/server session over stdio JSON-RPC.

Run the complete suite from the repository root:

```bash
scripts/run-e2e-docker.sh
```

The runner builds the image for Docker's native architecture, creates `artifacts/e2e/<UTC-timestamp>-<pid>/`, runs pytest, and prints the artifact path. Additional arguments pass directly to pytest:

```bash
scripts/run-e2e-docker.sh -- tests/e2e/test_mcp_protocol.py -v
scripts/run-e2e-docker.sh -- -k "visual or screenshot" -v
```

Coverage includes:

- virtual KWin engine tests for session lifecycle, AT-SPI2 observation, window geometry and control, closing one window by id (including ids that contain a quote), EIS pointer/keyboard/touch input, clipboard, cleanup, and error handling;
- failing-session lifecycle regressions that stub `kwin_wayland`/`dbus-run-session` on `PATH`: `session_start` must fail within its startup deadline and surface the captured session stderr plus stray stdout (including a newline-free partial line), and teardown must reap the entire owned process group even when the session leader was already reaped or a descendant ignores `SIGTERM`;
- a KWin bus-name readiness regression whose `kwin_wayland` stub creates the Wayland socket before starting the real compositor: `session_start` must wait until `org.kde.KWin` has an owner before it sets up EIS input, and must fail with a clear error when KWin exits first or never takes the name;
- an accessibility-bus check that `org.a11y.Bus` has an owner as soon as `session_start` returns, before any app or AT-SPI2 query could activate it, and that a failed activation (a `dbus-send` stub on `PATH`) is reported as a `Warning:` line in the `session_start` output, naming `KWIN_MCP_ATSPI_DIRECT_START` and starting nothing by default; with that opt-in, after the test simulates activation denial, the launcher and registry start directly so apps stay visible to AT-SPI2, with their `Exec=` split as dbus-daemon splits it (a quoted path containing a space, a quoted argument), or no accessibility bus is left behind when the direct start cannot complete; the `Exec=` parser itself is checked against argv observed from dbus-daemon activation;
- direct AT-SPI service-file and launch security coverage: child-only lookup after failed activation, both-service preflight and registry revalidation, the 128 KiB limit and file-descriptor guards, and bounded rollback of TERM-ignoring launcher/registry children and controlled lookup stalls; implicit `PATH` lookup, shell builtins/function evaluation, and `ENOEXEC` fallback are rejected, while explicit interpreter commands and valid `#!` scripts remain supported. These use controlled failures, not live NFS/FUSE stalls or SELinux-enforcing Fedora;
- the `org.a11y.Status.IsEnabled` switch: on in virtual sessions, and in live sessions reported, left off by default, switched on by `enable_accessibility=true`, and restored by `session_stop` and by a server exit while a tool is still running;
- exact input-schema checks for all 37 registered tools, plus installed-server stdio calls through every MCP wrapper;
- nested visual tests that start Xvfb and a test-owned KWin compositor inside the container, connect the installed MCP server to it, and verify pixels as well as accessibility state;
- KCalc before/after pixel transitions and a deterministic GUI probe for mouse hover, cursor inclusion, animation frame bursts, and CJK text (`GUI 검증 42`) rendered differently from a tofu control (`□□`);
- screenshot coordinate mapping at output scales 1.0 and 1.45 (the fractional scale is set through `kscreen-doctor`): a probe button found by pixel color in the screenshot is clicked at origin plus pixel and must activate, and single screenshots and frame bursts report the logical workspace as their coordinate space;
- screenshot retention and failure behavior, environment provenance, installed distribution metadata, console entry points, and process/socket cleanup.

The container uses software rendering and needs no `--privileged`, `--cap-add`, GPU, or device flags. Its nested Xvfb server is part of the visual fixture; the host does not need an X server. CI runs the suite natively in four images: Debian trixie (`docker/e2e.Dockerfile`), Fedora 44 (`docker/e2e-fedora.Dockerfile`), openSUSE Tumbleweed (`docker/e2e-opensuse.Dockerfile`), and Arch Linux (`docker/e2e-arch.Dockerfile`, amd64 only). A failure in any of them fails the workflow.

| Jobs | GitHub Actions runner |
|---|---|
| `amd64`, `fedora-amd64`, `opensuse-amd64`, `archlinux-amd64` | `ubuntu-26.04` |
| `arm64`, `fedora-arm64`, `opensuse-arm64` | `ubuntu-26.04-arm` |

A completed run retains `environment.json`, `junit.xml`, `pytest.log`, nested-KWin/Xvfb/MCP logs, and visual PNG evidence below its artifact directory. Failed runs also collect Docker inspect, container log, and process-list diagnostics.

One legacy success test for ScreenShot2 on KWin's exact `--virtual` backend remains intentionally skipped because that backend does not return capture data in this container. Error propagation for that path is tested at both engine and MCP stdio levels; screenshot success, cursor pixels, frame bursts, and fractional-scale coordinate mapping are tested through the explicit nested X11/scrot visual mode. Multi-output layouts, negative origins, ScreenShot2 `CaptureWorkspace` normalization, and Spectacle partial coverage are not part of the container suite. See [docker/README.md](docker/README.md) for the process topology, complete test-file inventory, evidence layout, and targeted commands.

## Contributing

Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code style guidelines, and the pull request process.

```bash
git clone https://github.com/isac322/kwin-mcp.git
cd kwin-mcp
uv sync
uv run ruff check src/
uv run ruff format --check src/
uv run ty check src/
```

## License

[MIT](LICENSE)
