Kwin Mcp Opencode
OpenCode plugin for kwin-mcp — auto-registers the kwin-mcp MCP server (uvx kwin-mcp) and ships the kwin-desktop-automation skill on backend startup.
61
+18 in 30 days
34
4 in 7 days
51.3
Multi-signal model
7 days ago
2026-09-27
Install and configure
opencode.jsonWrites to this project's opencode.json — applies to this repository only.
opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["@isac322/kwin-mcp-opencode@0.7.0"]
}Writes to ~/.config/opencode/opencode.json — applies to every project.
~/.config/opencode/opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["@isac322/kwin-mcp-opencode@0.7.0"]
}If you want to modify the plugin locally, install it into the project and reference the local path.
shell
pnpm add -D @isac322/kwin-mcp-opencodeOpenCode loads npm dependencies through its embedded runtime on startup and caches them locally — no manual global install needed.
Model Context Protocol server for Linux desktop GUI automation on KDE Plasma 6 Wayland
A Model Context Protocol (MCP) server that enables AI agents (Claude Code, Cursor, and other MCP clients) to launch, interact with, and observe any Wayland application in a fully isolated virtual KWin session -- without affecting the user's desktop. It also supports live desktop automation by connecting to an existing KWin session (real desktop or container) for collaborative workflows. With 33 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?
- Use Cases
- Quick Start
- Configuration
- Available Tools
- How It Works
- System Requirements
- Installation
- Limitations
- End-to-End Testing
- Contributing
- License
Why kwin-mcp?
- Isolated sessions -- Each session runs in its own
dbus-run-session+kwin_wayland --virtualsandbox. Your host desktop is never affected. - 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
libeiprotocol. - 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 33 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 for details.
[!NOTE] 0.7.0 only: the published kwin-mcp 0.7.0 package on PyPI did not cap its
mcpdependency, so a fresh install could resolvemcp2.x and the server failed at startup withModuleNotFoundError: No module named 'mcp.server.fastmcp'. Releases 0.8.0–0.9.x capmcp<2, and the next release targetsmcp>=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 first. On Debian 13 (Trixie):
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
# 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:
{
"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:
/plugin marketplace add isac322/kwin-mcp
/plugin install kwin-mcp@kwin-mcp
OpenCode — add the npm plugin to your opencode.json:
{
"$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.
Claude Code
Add to your project's .mcp.json:
{
"mcpServers": {
"kwin-mcp": {
"command": "uvx",
"args": ["kwin-mcp"]
}
}
}
Or if installed globally:
{
"mcpServers": {
"kwin-mcp": {
"command": "kwin-mcp"
}
}
}
Claude Desktop
Add to your claude_desktop_config.json:
{
"mcpServers": {
"kwin-mcp": {
"command": "uvx",
"args": ["kwin-mcp"]
}
}
}
Running Directly
# As an installed script
kwin-mcp
# As a Python module
python -m kwin_mcp
# 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
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:
claude mcp add kwin-mcp -- uvx kwin-mcp --screenshot-images
Any JSON MCP config takes the flag as another argument:
{
"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.
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. Set enable_clipboard=true to enable clipboard tools (requires wl-clipboard). Set keep_screenshots=true to preserve screenshot files after session_stop. Set isolate_home=true to create a temporary HOME with isolated XDG directories (config, data, cache, state), preventing apps from reading/writing host user settings. 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. |
session_connect |
dbus_address? str, wayland_display? str, keep_screenshots? 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. |
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) |
Capture the whole workspace 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. |
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. |
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"]), 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. |
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) |
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. Reports failure 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). 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 (2-5 fingers) for system gestures like workspace switching |
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. |
clipboard_set |
text str |
Set the clipboard text content. Same requirements as clipboard_get. |
Window Management (6 tools)
| Tool | Parameters | Description |
|---|---|---|
launch_app |
command str, env? dict |
Launch an application inside the running session. Returns PID and log path. |
list_windows |
(none) | List all accessible application windows with per-window titles and active/focused state markers via AT-SPI2 |
focus_window |
app_name str |
Activate and raise a window by application name (case-insensitive match), via KWin scripting |
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. |
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. |
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, fail with D-Bus call failed: ... and nothing is sent. 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. Set last_n_lines=0 for all output. |
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_msparameter (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 ownCoordinate spaceline in the same format asscreenshot. A frame whose mapping cannot be proven — a topology change or a failed observation during the capture — still writes its PNG but reportsCoordinate 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, andaccessibility_treealso return typedstructuredContentdescribed by their MCPoutputSchema: window ids, the active flag, and frame/client rectangles; element role, name, states, and screen rectangle (or the reason it is unavailable); and applications with their windows. 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.
How It Works
Claude Code / AI Agent
|
| MCP (stdio)
v
kwin-mcp server (33 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)
|
|-- 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_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, and clipboard_get are marked read-only. window_close, session_stop, dbus_call, and the mouse, keyboard, and touch input tools other than mouse_move are marked destructive: injected input can trigger any action in the focused app, such as deleting a file or sending a message. session_start and launch_app are marked destructive too: both run arbitrary commands. mouse_move, focus_window, clipboard_set, and session_connect change state but are not marked destructive. The annotations are hints for the client; kwin-mcp does not enforce them.
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, screenshot_after_ms bursts report captured frames, and long mouse_click/touch_tap holds, touch gestures, mouse_drag, and long keyboard_type strings report their progress. 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:
- D-Bus isolation --
dbus-run-sessioncreates a private session bus. The isolated session's services (KWin, AT-SPI2, portals) are invisible to the host. - Display isolation --
kwin_wayland --virtualcreates its own Wayland compositor with a virtual framebuffer. No windows appear on the host display. - 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.
- Home directory isolation (optional) -- When
isolate_home=trueis set insession_start, a temporary HOME directory is created with isolated XDG directories (XDG_CONFIG_HOME,XDG_DATA_HOME,XDG_CACHE_HOME,XDG_STATE_HOME). Apps in the session cannot read or modify host user settings (e.g.~/.config/kdeglobals), improving test reproducibility and safety.XDG_RUNTIME_DIRis intentionally not isolated because the Wayland socket resides there.
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:
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
CaptureWorkspacealready 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 partialwith 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 * scalepixels. kwin-mcp locates those windows withxwininfoand 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.).
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) |
| 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, pycairo, and 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):
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, and also install your distribution's libdbus development package (it provides the dbus-1 pkg-config file that dbus-python needs).
Runtime Packages
Debian 13 (Trixie)
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.
sudo apt-get install -y --no-install-recommends kwin-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
Arch Linux / Manjaro
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
Fedora (KDE Spin)
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
openSUSE (KDE)
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
Kubuntu / KDE Neon (Plasma 6 releases only)
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.
sudo apt install kwin-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
Installation
Install the system and build 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
mcpdependency, so a fresh install could resolvemcp2.x and the server failed at startup withModuleNotFoundError: No module named 'mcp.server.fastmcp'. Releases 0.8.0–0.9.x capmcp<2, and the next release targetsmcp>=2.2.0,<3; upgrading kwin-mcp fixes the error.
Using uv (recommended)
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
pip install kwin-mcp
From source
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_typesupports US QWERTY only. For non-ASCII text (Korean, CJK, etc.), usekeyboard_type_unicode. 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 withsession_connectkeeps the desktop's own keymap, so its active layout must be US forkeyboard_typeandkeyboard_keyto produce the expected characters.Unicode typing can paste through the clipboard -- When
wtypeis unavailable or unsupported by the session,keyboard_type_unicodebriefly puts the text on the clipboard, then restores the previous selection and keeps serving it until another copy replaces it, even aftersession_stopdisconnects 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. Thex-kde-passwordManagerHintmarker 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_unicodetherefore 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 becausewl-copycan hang in isolated sessions. Enable withenable_clipboard=trueinsession_start, and ensurewl-clipboardis 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_treeorfind_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_callto 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=2when the target is the app itself.Element coordinates are screen-global, or unavailable --
find_ui_elements,accessibility_treeandwait_for_elementreport rectangles in the same global screen coordinatesmouse_clickandtouch_taptake (@ 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 spaceline reportscoverage partialwith 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.screenshotcaptures the current virtual desktop's whole workspace only; no single-window capture exists --screenshotandscreenshot_after_msframes use KWin ScreenShot2CaptureWorkspace(with a Spectacle fallback, or the opt-in X11/scrot backend enabled byKWIN_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_callcannot reachorg.kde.KWin.ScreenShot2.CaptureWindoweither, because it cannot pass the unix file descriptor the capture methods require. In a live session, KWin also refuses ScreenShot2 calls withorg.kde.KWin.ScreenShot2.Error.NoAuthorizedunless the caller's executable (/proc/<pid>/exe) matches the first token ofExec=in a.desktopfile that declaresX-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2. A user-level file under~/.local/share/applicationsis picked up afterkbuildsycoca6with no KWin restart (kwin-mcp's own virtual sessions disable this check):[Desktop Entry] Type=Application Name=kwin-mcp ScreenShot2 Exec=/path/to/copies-venv/bin/python3 X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2The grant applies to every process running that exact binary. For a Python program the authorized executable is the interpreter, so whitelisting
/usr/bin/python3authorizes every Python process that runs on it; KWin compares canonical paths, so a default venv'sbin/python3is a symlink that resolves to the system interpreter and grants it too. Only a venv created withpython -m venv --copieshas its own interpreter binary that can be whitelisted on its own. Even with the whitelist, a window on an inactive desktop stops redrawing, soCaptureWindowreturns the last frame drawn while it was visible, not its live state; only KWin's screencast path (zkde_screencast_unstable_v1stream_window) keeps an off-screen window rendering, gated by the same authorization mechanism underX-KDE-Wayland-Interfaces. KWin scripts and QMLWindowThumbnailcannot 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:
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:
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-sessiononPATH:session_startmust 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 ignoresSIGTERM; - a KWin bus-name readiness regression whose
kwin_waylandstub creates the Wayland socket before starting the real compositor:session_startmust wait untilorg.kde.KWinhas 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.Bushas an owner as soon assession_startreturns, before any app or AT-SPI2 query could activate it, and that a failed activation (adbus-sendstub onPATH) is reported as aWarning:line in thesession_startoutput; - exact input-schema checks for all 33 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 for the process topology, complete test-file inventory, evidence layout, and targeted commands.
Contributing
Contributions are welcome! See CONTRIBUTING.md for development setup, code style guidelines, and the pull request process.
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
Similar plugins
Ocm
@wntic/ocm
Plugin marketplace for opencode: install skills, agents, commands, JS plugins and MCP servers from any git repo — once, globally, kept up to date
Browser
opencode-browser
OpenCode plugin that integrates Browser MCP for browser automation
Browser Mcp
opencode-browser-mcp
OpenCode plugin that integrates Browser MCP for browser automation