Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
},
"metadata": {
"description": "kwin-mcp marketplace \u2014 MCP server and skill for Linux KDE Plasma 6 Wayland desktop GUI automation (virtual sessions for headless testing, live sessions for real-desktop / container / kiosk control).",
"version": "0.8.1"
"version": "0.8.2"
},
"plugins": [
{
"name": "kwin-mcp",
"source": "./integrations/claude-code",
"version": "0.8.1",
"version": "0.8.2",
"description": "Bundles the kwin-mcp MCP server (uvx kwin-mcp) plus the kwin-desktop-automation skill that teaches session-mode selection, the observe-act-verify loop, US-QWERTY vs Unicode typing, and platform pitfalls (surface-local coordinates, QMenu invisibility, EIS edge limits, clipboard opt-in).",
"category": "automation",
"keywords": [
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.8.2] - 2026-09-07

### Fixed

- The last-resort fallback of the at-spi-bus-launcher resolver returned `candidates[0]` (`/usr/libexec/...`, the Debian/Ubuntu/Fedora layout) when no candidate file existed and `shutil.which` found nothing — a dead path on Arch. The fallback is now the Arch default `/usr/lib/at-spi-bus-launcher`, and the resolved path is shell-quoted when embedded into the session's bash wrapper
- The reason an `InputBackend` (KWin EIS) setup failed was silently swallowed when `session_start`/`session_connect` degraded to "no input backend"/ydotool; the exception text is now logged as a warning before the degradation (backend selection unchanged)

### Internal

- Test cleanups (dead assignment, tautological assert) and type hints on test fakes/helpers

## [0.8.1] - 2026-09-07

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ sudo apt install wl-clipboard wtype wayland-utils

## Installation

> **Note**: the `kwin-mcp` name on PyPI tracks the upstream project (`isac322/kwin-mcp`, v0.7.0, MCP SDK 1.x). This repository is the actively maintained fork (v0.8.1, MCP SDK 2.x); install it from git or from a source checkout. The `uvx kwin-mcp` config examples in the [Configuration](#configuration) section resolve to the PyPI package.
> **Note**: the `kwin-mcp` name on PyPI tracks the upstream project (`isac322/kwin-mcp`, v0.7.0, MCP SDK 1.x). This repository is the actively maintained fork (v0.8.2, MCP SDK 2.x); install it from git or from a source checkout. The `uvx kwin-mcp` config examples in the [Configuration](#configuration) section resolve to the PyPI package.

### Using uv (recommended)

Expand Down
2 changes: 1 addition & 1 deletion integrations/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://claude.ai/schemas/plugin.json",
"name": "kwin-mcp",
"version": "0.8.1",
"version": "0.8.2",
"description": "MCP server + skill for Linux KDE Plasma 6 Wayland GUI automation. Provides 30 MCP tools (mouse, keyboard, touch, clipboard, screenshot, AT-SPI2 accessibility tree, window mgmt, D-Bus passthrough) plus the kwin-desktop-automation skill that guides session-mode selection, observation/action sequencing, and platform pitfalls.",
"author": {
"name": "Byeonghoon Yoo",
Expand Down
2 changes: 1 addition & 1 deletion integrations/opencode/plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@isac322/kwin-mcp-opencode",
"version": "0.8.1",
"version": "0.8.2",
"description": "OpenCode plugin for kwin-mcp \u2014 auto-registers the kwin-mcp MCP server (uvx kwin-mcp) and ships the kwin-desktop-automation skill on backend startup.",
"type": "module",
"main": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "kwin-mcp"
version = "0.8.1"
version = "0.8.2"
description = "MCP server for Linux desktop GUI automation on KDE Plasma 6 Wayland — virtual testing and live desktop automation"
readme = "README.md"
license = "MIT"
Expand Down
13 changes: 11 additions & 2 deletions src/kwin_mcp/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import json
import logging
import os
import shlex
import shutil
Expand All @@ -24,6 +25,8 @@
from kwin_mcp.screenshot import capture_frame_burst, capture_screenshot_to_file
from kwin_mcp.session import LiveSession, Session, SessionConfig

logger = logging.getLogger(__name__)

# Install hints for external binaries
_INSTALL_HINTS: dict[str, str] = {
"wl-paste": (
Expand Down Expand Up @@ -246,7 +249,10 @@ def session_start(
time.sleep(0.5)
try:
self._input = InputBackend(info.dbus_address)
except RuntimeError:
except RuntimeError as exc:
logger.warning(
"KWin EIS input backend unavailable, degrading to no input backend: %s", exc
)
self._input = None

input_status = "Input backend: KWin EIS" if self._input else "No input backend available"
Expand Down Expand Up @@ -306,7 +312,10 @@ def session_connect(
try:
self._input = InputBackend(dbus_addr)
result += "\nInput backend: KWin EIS"
except RuntimeError:
except RuntimeError as exc:
logger.warning(
"KWin EIS input backend unavailable, falling back to ydotool if present: %s", exc
)
self._input = None
if shutil.which("ydotool"):
result += "\nInput backend: ydotool (EIS unavailable)"
Expand Down
11 changes: 9 additions & 2 deletions src/kwin_mcp/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import contextlib
import os
import queue
import shlex
import shutil
import signal
import subprocess
Expand All @@ -29,13 +30,19 @@
"/usr/lib/at-spi2-core/at-spi-bus-launcher",
)

# Last-resort default when no candidate exists and PATH lookup fails: the
# Arch layout (the primary development platform). A literal rather than a
# candidate index so reordering _AT_SPI_LAUNCHER_CANDIDATES cannot silently
# repoint the fallback at another distro's path.
_AT_SPI_LAUNCHER_FALLBACK = "/usr/lib/at-spi-bus-launcher"


def _at_spi_bus_launcher() -> str:
"""Locate the AT-SPI bus launcher binary for the current distribution."""
for candidate in _AT_SPI_LAUNCHER_CANDIDATES:
if Path(candidate).exists():
return candidate
return shutil.which("at-spi-bus-launcher") or _AT_SPI_LAUNCHER_CANDIDATES[0]
return shutil.which("at-spi-bus-launcher") or _AT_SPI_LAUNCHER_FALLBACK


class SessionType(Enum):
Expand Down Expand Up @@ -400,7 +407,7 @@ def _build_wrapper_script(self, config: SessionConfig) -> str:
# instead of dbus-broker (which reuses the host's AT-SPI bus).
# The launcher path is distro-specific: resolved on the Python side
# (_at_spi_bus_launcher) before this wrapper is assembled.
{_at_spi_bus_launcher()} --launch-immediately &
{shlex.quote(_at_spi_bus_launcher())} --launch-immediately &
AT_SPI_PID=$!
sleep 0.2

Expand Down
5 changes: 3 additions & 2 deletions tests/test_input_eis_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ def test_setup_translates_dbus_exception_to_runtime_error() -> None:
assert isinstance(caught.value.__cause__, dbus.DBusException)


def test_setup_translates_connect_failure_from_interface_proxy(monkeypatch) -> None:
def test_setup_translates_connect_failure_from_interface_proxy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A DBusException surfacing later (Interface/connectToEIS stage) is also
translated: the whole dbus block is covered, not just get_object."""

Expand All @@ -59,7 +61,6 @@ def connectToEIS(self, *args: Any) -> None: # noqa: N802
raise dbus.DBusException("org.kde.KWin.EIS.RemoteDesktop: not supported")

monkeypatch.setattr(input_module.dbus, "Interface", _ThrowingIface)
client = _client_with_bus(object())

class _OkBus:
def get_object(self, *args: Any, **kwargs: Any) -> object:
Expand Down
54 changes: 43 additions & 11 deletions tests/test_screenshot_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,23 @@

from __future__ import annotations

from typing import TYPE_CHECKING

import dbus
import pytest

if TYPE_CHECKING:
from pathlib import Path

import kwin_mcp.screenshot as screenshot_module
from kwin_mcp.screenshot import capture_screenshot_to_file


def test_dbus_success_skips_spectacle(monkeypatch, tmp_path) -> None:
def test_dbus_success_skips_spectacle(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""A successful D-Bus capture returns immediately; spectacle is not called."""
calls: list[str] = []

def fake_dbus(address: str, path, *, include_cursor: bool = False):
def fake_dbus(address: str, path: Path, *, include_cursor: bool = False) -> Path:
calls.append("dbus")
path.write_bytes(b"png")
return path
Expand All @@ -40,15 +45,23 @@ def fake_dbus(address: str, path, *, include_cursor: bool = False):
assert path.parent == tmp_path


def test_dbus_failure_falls_back_to_spectacle(monkeypatch, tmp_path) -> None:
def test_dbus_failure_falls_back_to_spectacle(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A D-Bus failure (DBusException) degrades to spectacle, not an error."""
calls: list[str] = []

def failing_dbus(address: str, path, *, include_cursor: bool = False):
def failing_dbus(address: str, path: Path, *, include_cursor: bool = False) -> Path:
calls.append("dbus")
raise dbus.DBusException("not authorized")

def fake_spectacle(address: str, socket: str, *, output_path, include_cursor: bool = False):
def fake_spectacle(
address: str,
socket: str,
*,
output_path: Path,
include_cursor: bool = False,
) -> None:
calls.append("spectacle")
output_path.write_bytes(b"png")

Expand All @@ -60,13 +73,21 @@ def fake_spectacle(address: str, socket: str, *, output_path, include_cursor: bo
assert path.exists()


def test_both_routes_failing_reports_both_errors(monkeypatch, tmp_path) -> None:
def test_both_routes_failing_reports_both_errors(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""When D-Bus and spectacle both fail, the error names both causes."""

def failing_dbus(address: str, path, *, include_cursor: bool = False):
def failing_dbus(address: str, path: Path, *, include_cursor: bool = False) -> Path:
raise dbus.DBusException("not authorized")

def failing_spectacle(address: str, socket: str, *, output_path, include_cursor: bool = False):
def failing_spectacle(
address: str,
socket: str,
*,
output_path: Path,
include_cursor: bool = False,
) -> None:
raise RuntimeError("spectacle not found")

monkeypatch.setattr(screenshot_module, "capture_screenshot_dbus", failing_dbus)
Expand All @@ -76,15 +97,24 @@ def failing_spectacle(address: str, socket: str, *, output_path, include_cursor:
capture_screenshot_to_file("unix:path=/tmp/dbus", "wayland-0", output_dir=tmp_path)


def test_frame_burst_skips_empty_frames(monkeypatch, tmp_path) -> None:
def test_frame_burst_skips_empty_frames(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Regression guard for the frame burst path: an empty frame is skipped in
phase 2 instead of aborting the burst (pre-refactor behavior)."""

from PIL import Image

def fake_raw_frame(iface, options):
saved: list[object] = []

def fake_raw_frame(
iface: dbus.Interface,
options: dict[str, dbus.Boolean],
) -> tuple[bytes, int, int, int]:
return b"", 0, 0, 0

def fake_frombytes(*args: object, **kwargs: object) -> object:
saved.append(args)
return object()

class _StubBus:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
Expand All @@ -95,8 +125,10 @@ def get_object(self, *args: object, **kwargs: object) -> object:
monkeypatch.setattr(screenshot_module.dbus.bus, "BusConnection", _StubBus)
monkeypatch.setattr(screenshot_module.dbus, "Interface", lambda *a: object())
monkeypatch.setattr(screenshot_module, "_capture_raw_frame", fake_raw_frame)
monkeypatch.setattr(Image, "frombytes", fake_frombytes)
frames = screenshot_module._capture_frame_burst_dbus(
"unix:path=/tmp/dbus", tmp_path, [0], include_cursor=False
)
assert frames == []
assert Image # PIL import inside the function is exercised by the call above
# Phase 2 must not attempt a PNG conversion of the empty frame either.
assert saved == []
10 changes: 8 additions & 2 deletions tests/test_session_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,18 +114,24 @@ def test_at_spi_launcher_falls_back_to_which(monkeypatch) -> None:
assert session_module._at_spi_bus_launcher() == "/usr/local/bin/at-spi-bus-launcher"

monkeypatch.setattr(session_module.shutil, "which", lambda name: None)
assert session_module._at_spi_bus_launcher() == "/nonexistent/a"
assert session_module._at_spi_bus_launcher() == "/usr/lib/at-spi-bus-launcher"


def test_wrapper_script_contains_resolved_launcher(monkeypatch) -> None:
"""The wrapper embeds the resolved launcher path, not a hardcoded one."""
"""The wrapper embeds the resolved launcher path, not a hardcoded one, and
shell-quotes it so paths with special characters stay one argument."""
monkeypatch.setattr(session_module, "_at_spi_bus_launcher", lambda: "/resolved/launcher")
session = Session()
session._socket_name = "wayland-mcp-test"
script = session._build_wrapper_script(SessionConfig())
# shlex.quote leaves a plain path untouched (no spurious quotes).
assert "/resolved/launcher --launch-immediately" in script
assert "/usr/lib/at-spi-bus-launcher" not in script

monkeypatch.setattr(session_module, "_at_spi_bus_launcher", lambda: "/opt/my tools/launcher")
quoted = session._build_wrapper_script(SessionConfig())
assert "'/opt/my tools/launcher' --launch-immediately" in quoted


def test_launch_app_strips_host_display(monkeypatch, tmp_path) -> None:
"""launch_app removes the host DISPLAY from the app environment so X11
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading