kde-desktop-mcp
Provides screenshot-free control of a Hyprland session: reads application UI elements through AT-SPI (apps, tree, find, get_text), performs element actions (act, set_text, focus, click), sends keyboard input via /dev/uinput, moves the cursor with hyprctl dispatch movecursor, types text with wtype (or wl-copy), and focuses/activates windows through hyprctl clients and focuswindow, restricted by a per-app allowlist.
Provides screenshot-free control of a KDE Plasma (Wayland) desktop: reads running applications' UI elements from the AT-SPI accessibility tree (apps, tree, find, get_text), acts on individual elements (act, set_text, focus, click), sends keyboard input through /dev/uinput, writes text via wl-copy/Ctrl+V, and manages windows with KWin scripts, all restricted by a per-app allowlist.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
kde-desktop-mcp
A screenshot-free desktop control MCP server for KDE Plasma (Wayland), Hyprland and Windows. It lets MCP clients such as Claude Code read the inside of applications through the accessibility tree and act on elements directly. Written in Rust; a single executable.
Verified on a real KDE Plasma desktop. Hyprland was verified in a nested session inside KDE. On Windows, keyboard, mouse and screenshots were tried under Wine; tree reading (
tree,find,get_text,focus,click) has not yet been tried on real Windows. Please share results fromtools/mcp_smoke_test.py.
How it works
Instead of looking at the screen, Claude reads the elements of a window as a
list like [55] button "Save" and acts on an element by its number.
Layer | KDE Plasma (Wayland) | Hyprland | Windows |
Reading | AT-SPI (D-Bus) | AT-SPI (D-Bus) | UI Automation cache requests |
Element actions | AT-SPI actions, EditableText | AT-SPI actions, EditableText | Invoke, Toggle, ExpandCollapse, SelectionItem, Value patterns |
Keyboard |
|
| Win32 |
Mouse | Absolute uinput pointer | Hyprland |
|
Typing text | Direct insertion via AT-SPI into Qt multi-line fields (clipboard untouched); elsewhere in-process clipboard + Ctrl+V, after which the previous clipboard text is put back |
|
|
Windows | KWin scripting (D-Bus) | Hyprland socket: |
|
Screenshot (fallback) | KWin |
| GDI |
The code lives in rust/src/: main.rs holds the MCP protocol, the
allowlist, element ids and the tools; render.rs the tree and search output;
backend.rs defines the platform interface. Platform code is in linux/
(KDE and Hyprland share an AT-SPI layer) and windows/. Claude sees the same
tools on every platform.
The desktop is chosen automatically: Windows on Windows; on Linux, Hyprland if
HYPRLAND_INSTANCE_SIGNATURE is set, otherwise KDE. To choose by hand on
Linux, set DESKTOP_MCP_BACKEND to kde or hyprland.
On Hyprland the active window is matched to its AT-SPI application by process id (pid), so the toolkit's focus flag does not have to be trusted.
focus first asks the accessibility layer for focus. If the app refuses (common
with Qt), it clicks the centre of the element for real.
Why it is fast:
It talks to AT-SPI over D-Bus directly, without libatspi, and pipelines requests: all attributes of an element, and every level of the tree, are requested at once, so the time is bound by the app's response speed rather than by round-trip latency.
On Windows a window's whole subtree, with the properties needed, arrives in a single UI Automation cache request (
BuildUpdatedCache).KWin scripts go over D-Bus instead of
qdbus6processes, Hyprland commands over Hyprland's socket instead ofhyprctlprocesses.Start-up takes 2 ms; the virtual keyboard and mouse are prepared in parallel at start-up.
On KDE the waits were measured and shortened (uinput events are processed in order);
focusremembers focus methods that do not work in an app and skips them;type_textstarts no process; screenshots come straight from KWin instead ofspectacle.Output is short, so Claude reads less: one space of indent per level, sibling leaves with the same role and actions share one line (
list item ×3: [8] "a" [9] "b" [10] "c"), and the "Set Focus" action Qt gives almost every element is not listed. A Dolphin + Katetreewent from 15,182 to 7,092 characters.In browsers too: Firefox's "clickAncestor" action on every element and the embedded-object placeholder (U+FFFC) of web pages are not listed; real action names are read instead of Firefox's broken batched names (
;;).tree(document=true)shows only the active tab's page (found in one call through the window's EMBEDS relation). The same Firefox page went from 5,943 to 1,421 characters.In Chromium and Electron apps, the
showContextMenuaction every element carries is not listed either, and the genericdoDefaultof their frame, panels and pages counts as implied: it no longer keeps nameless panels as lines. A Ferdium (WhatsApp) tree went from 19,906 characters, cut at 300 nodes behind six nested panels, to 7,759 for the whole window; withinteractive: truefrom 19,924 to 2,586, the chat list's rows included (see below).actwithoutactionnever picksshowContextMenu: an element with no other action is an error that says to useclick, oraction: "showContextMenu"when the menu is what is wanted.Actions a role implies are left out (a link's jump, a button's press, a tab's switch, a web section's click); a button or link without any says
actions=none. Nameless, text-free wrappers (landmarks, forms, scroll panes) are left out with their children lifted, as are empty nameless leaves (separators, images inside buttons) and labels around a same-named button. A Google results page in Firefox went from 6,976 to 5,702 characters. A static text or label leaf that only repeats what its parent's line shows (a heading's or link's own text node, a status bar's count) is left out oftree, and offindwhen the parent is listed as high; asked for withrole, it is listed. A YouTube page'stree(document=true)went from 3,792 to 3,327 characters.get_textin Chromium no longer reads such leaves a second time after the text of the element that holds them (a WhatsApp page: 3,035 instead of 5,010 characters, the same text), and the U+FFFC in a container's text goes to the child object it stands for (an avatar, an emoji image) rather than to a text leaf whose words the text already holds.On Linux, a
find,treeof an app, or element picked byapp+queryreuses the app's tree read by an earlier call while the app provably did not change: it sent no AT-SPI change event since (children, name, text, any state, visible data, windows), no app or window came or went, no tool sent input or activated a window, the user did not touch the keyboard or mouse, and the read is under 2 s old. Only apps known to send these events (Qt, Firefox, Chromium, or any app once it sent one) and no page still loading. An element taken from such a read is checked to still be there (one call) before it is used. Aget_textby query in Firefox went from 8.7 ms to 1.1 ms. Clicking by query also measures the element in the same round trip as the lookup, from the window found in the read.appslists blocked apps by name on one line and skips untitled windows.An AT-SPI call waits at most 2 s; an app that does not answer cannot lock the server. Actions that open a menu (Qt does not reply until the menu closes) count as sent after 400 ms.
Tool calls are handled concurrently: a pending
wait_foror a slow app does not hold uppingand other calls. If Claude cancels a call (notifications/cancelled), it is stopped. Tools that send keyboard or pointer input still run one at a time, so keystrokes never interleave.Popup menus and overlays of web pages are part of
tree/findoutput. Firefox does not mark these layers as SHOWING; containers that are visible and lie inside the window are entered as well, and so are elements without a layout box (display: contents, -1 extents): YouTube wraps its whole page in one, which hid every element of the page fromfindandquery. Content scrolled out of view is skipped. Example: Gemini's upload menu ("Dosya yükleyin") is now found without a screenshot.Matching (
find,query,wait_for) compares names and text case-folded (Turkish İ/ı as i), with whitespace runs, no-break spaces and the placeholders of web containers as one space and soft hyphens and zero-width characters left out; up to 1000 characters of an element's text are searched. When nothing matches that way, an element whose name or text holds every word of the query, in any order and accents aside, does ("cozum yontemleri" for "Çözüm Yöntemleri"). When still nothing visible matches,find(and the error of a tool givenquery) lists up to five matches scrolled out of view in the web page, with ids (actandget_textwork on them as they are;actwithaction: "scroll_to"brings one into view forclick), else up to five visible elements with the closest names (no matches; closest: …), so the next call can use an id instead of a screenshot.Read tools (
apps,tree,find,get_text,wait_for,wait_idle,screenshot,pause,clipboard_get,windows,doctor) carry MCP'sreadOnlyHint; Claude Code runs several of them in parallel when asked for in one message. The others carrydestructiveHint: true forclose_windowand the input tools (which can do whatever the user could), false foractivate_window,move_windowandlaunch.
Related MCP server: blade-computer-use
On-screen indicator
While the agent uses the keyboard or mouse, the screen edges glow orange, a
label at the top says what it is doing (e.g. "Claude · typing") and a ring
marks each pointer target. All of them are layers that pass clicks through to
the window below (wlr-layer-shell; KDE Plasma and Hyprland). They disappear
2.5 s after the last action and are hidden before a screenshot is taken. The
server starts the indicator on the first action as desktop-mcp --indicator;
it exits with the server. To turn it off, set the environment variable
DESKTOP_MCP_INDICATOR=0 in the MCP configuration.
When the user takes over
The agent and the user share one keyboard and mouse. Input tools (act,
set_text, focus, click, drag, scroll, key, type_text, mouse,
activate_window, file_dialog, move_window, launch, and any tool given
window) wait for the user:
Pause. While the user typed or moved the pointer in the last 1.5 s, they fail with
paused: the user is using the keyboard ...without sending anything; the agent retries once the user stops. User input during a batch, a drag or a repeatedkeystops the remaining input (interrupted: ...). Read tools and the clipboard tools keep working.Stop shortcut: Meta+Alt+Esc. It ends a running drag or batch and refuses every input tool with
stopped by the useruntil the agent callsactivate_windowon its own (not as a batch step and not throughwindow); the agent is told to ask you first. The indicator shows "paused" or "stopped" when it refuses. (Plasma 6 already uses Meta+Esc for System Monitor.)
How it is seen (doctor shows it under "takeover detection"):
With read access to
/dev/input(the user is in theinputgroup), the server reads the real keyboards and pointers there: read-only, never grabbed, its owndesktop-mcp-*devices left out. This is exact, and the stop chord is seen on every compositor. Note that theinputgroup lets every program of the user read all keystrokes.Otherwise it follows the compositor's idle notifications (
ext-idle-notify-v1, KDE and Hyprland). They count the agent's own input too, so user input is only noticed outside of it (between steps, or when it lasts longer than the agent's).KDE registers the stop shortcut with KGlobalAccel: it appears under System Settings > Shortcuts > Desktop MCP, where it can be changed. On Hyprland the stop shortcut needs
/dev/input.
Settings (environment variables in the MCP configuration):
DESKTOP_MCP_TAKEOVER_MS (default 1500; 0 turns the pause off) and
DESKTOP_MCP_STOP_KEY (default meta+alt+esc, key names as for the key
tool; off turns it off). Not available on Windows yet.
Tools
apps,tree,find,get_text: reading and searching. Withdocument: true,treelists only the document of the active window (in a browser, that tab's page without tabs and toolbars).get_texton a web page or another container returns the text of everything in it in reading order (web containers' own text is only a placeholder per child element). It returns at mostlimitcharacters (default 10,000, about 2,500 tokens) fromoffset(default 0); a cut reply ends with… cut at character N; get_text with offset=N reads on. Whole web pages used to come back as 20,000 characters while the part wanted was near the top. Atree/findline shows up to 80 characters of an element's text with its line breaks (text="Shopping list\nmilk\nbread\n"), so a short document's lines and final newline are visible withoutget_text;…after the closing quote marks a text that goes on.Large trees:
tree(interactive: true)lists only elements one can operate (actions, editable/checkable/selectable/expandable, and on Linux focusable named table rows, cells, list and tree items: a web app's chat list whose rows say onlyclickAncestor/showContextMenu) under their nearest named ancestor; on a GitHub issues page in Firefox it was 15,339 instead of 24,687 characters and still listed all 259 links, buttons and fields. A reply is capped atmax_chars(default 20,000) andmax_nodes; a cut one ends with… N more nodes; continue with tree(…, after=ID), which lists the rest after that element (ids are stable, so pages join up exactly).screenshot: trueadds the image of the app's window when it is in front.set_textreads back what it wrote: the reply says "verified", or it fails saying what the field holds (a length limit, reformatting). Firefox's input fields report accessibility writes as successful and do nothing, so when single-line text does not take, it focuses the element, selects all with Ctrl+A and types it. An editable element without a text interface (Qt 6's editable combo boxes, such as a KDE file dialog's name field) is typed into the same way and read back from its name. Password fields cannot be read back.scroll: turns the wheel over an element (a list, a page, a panel).Verified actions of
act(one tool rather than new ones:actis loaded up front, so they cost no tool-search turn, and the schema grew by about 570 characters instead of three new tools of 1,700 each). Each reads the state back and replies with it (Toggle -> checked (verified)), or fails saying what it sent and what it reads instead (sent Toggle, but it reads unchecked afterwards); one already in the asked state sends nothing and says so (already checked; nothing sent), so it is safe to repeat:action: "check"/"uncheck": a check box, check menu item, radio button (only checked: another one of its group unchecks it), toggle button or switch, or anything the platform marks checkable. Runs its check, uncheck, toggle, press or click action. A checkable list or tree row (Qt's item views, GTK's toggle cells) only through an action named check/uncheck, or toggle outside Qt (Qt's Toggle selects the row); otherwise focus through accessibility and Space (fulltier)."toggle"flips it, or the expansion of an element that is expandable, not checkable, and has no action named Toggle (one that has it, a Qt row, runs that action as before).action: "expand"/"collapse": a tree item, disclosure button, accordion or combo box through an action named for it (expand,collapse, GTK'sexpand or contract); buttons and combo boxes may use their press. A list or tree row is never pressed for it (that opens a file in Dolphin): an element with no such action (Qt's tree items) gets focused through accessibility and sent Right or Left, which needs thefulltier; when it cannot be focused that way the reply says to click it and press the key.option: "UTF-8"(withaction: "select", or alone): picks the entry with that label (matched likemenulabels: case,&markers, ellipses aside; a unique part is enough) in a combo box, list, tab list or menu button, from its entries in the tree even while it is closed. In order, each only while the one before is not verified: the list's AT-SPI Selection (SelectChild, afterClearSelectionin a multi-selection list; skipped in Firefox and Chromium pages, whose scripts would not see it), the entry's own select/click/press action (not Qt's Toggle, which only marks a row), then for a closed combo box (with thefulltier) focus through accessibility and as many Down/Up presses as entries lie between the shown one and the wanted one. Verified by the value a combo box is named after (Qt, GTK), else the entry'sselectedstate (Firefox); a list where other entries stay selected too says how many. A control that lists no entries is opened through its action only when it opens a popup (a combo box, a menu, a button marked as having a popup or with a Show Menu/open/expand action) and the entry is taken from the popup menu; anything else (a plain button, a row) fails with nothing sent.action: "scroll_to": brings an element scrolled out of view (an id from the off-screen list of a miss) into view through AT-SPIComponent.ScrollTo(on the element, else its nearest ancestors); when that is missing or does not show it, turns the wheel over its scrollable container (5 notches a turn, at most 10, toward the element) until it is showing, and says that the pointer moved. The wheel turns over the part of the container inside its window (in a web page over the page, not a<ul>or<table>in it), and stops when the user takes over. An element that is hidden (not visible: in a closed popup or a collapsed branch; a Qt row scrolled out of a showing list counts as scrolled away) gets no wheel.
None of them opens a Firefox menu or
<select>popup through accessibility (seemenu): such a call fails with nothing sent and says to click the control. Their key fallbacks stop when the user takes over, and the act call's confirmation covers them. On Windows (roles compared without spaces:checkbox,listitem), check/uncheck/expand/collapse use the UI Automation Toggle and ExpandCollapse actions andselectthe entry's SelectionItem; Selection and ScrollTo are not supported there (untested on Windows). Verified so far with unit tests only; not yet tried against Kate, Dolphin or Firefox.Modifier clicks:
click,mouseandscrolltakemodifiers(ctrl,shift,alt,meta), pressed before the pointer action and released in reverse order afterwards, also when it fails (ctrl+click adds to a selection, shift+click selects a range, ctrl+scroll zooms).clicks: 2is a double andclicks: 3a triple click (select a line);buttonisleft,rightormiddle.hold: presses a key, chord or mouse button and leaves it down across calls (down: falsereleases it; withoutkey/buttonit releases everything). Every input reply ends with what is still held (; held: shift, left). Everything held is released when a call fails, when a call is cancelled (notifications/cancelled) and when the server exits (end of input, SIGTERM/SIGINT/SIGHUP); on Linux the kernel also releases the keys of the virtual devices when the process dies. Meant for modifiers and buttons: a held letter auto-repeats like a real key.cursor_position: where the cursor is, in the coordinatesmousetakes (KDE:workspace.cursorPosthrough a KWin script; Hyprland:cursorpos; Windows:GetCursorPos). Read-only.drag: presses, moves in steps and releases (drag and drop, sliders, selections). Each end is an element id (from_id/to_id), a screen coordinate, or withimage: truea pixel of the last screenshot.act,set_text,focus,click: act on an element. The element is picked by itstree/findnumber (id) or in one call byapp+query(+role): a visible element whose name equalsquery, else one whose name or text contains it. Window, tab and page titles (frame, window, page tab, and a web page's document) count only when nothing else matches orrolenames that role;wait_forskips them unlessrolenames them. Last come elements holding every word of the query, accents aside.Ties: when several elements match equally well (the best of those tiers), these tie-breaks apply in turn, while the matches are in several windows of the app: (1) a window whose title contains the call's
windowargument beats the others (before the active window, whose ACTIVE state can lag just afterwindowraised another); (2) the app's active window beats its background windows; (3) a dialog (dialog, alert, file chooser) beats the app's other windows, and of stacked dialogs the one opened last (listed last) wins. Then, wherever they are: (4) an element one operates (a button, entry, list item: an interactive role or state) beats one that only shows the name (a label, a heading, a list named the same); (5) for input tools, an element with an on-screen area beats one without (zero size), whether its window is in front or not (checked for up to 12 candidates). A tie left after that is refused by the input tools (act,click,focus,set_text,scroll), which send nothing and list at most 6 candidates with ids, so the next call passes oneid(orrole, orwindow):ambiguous: 2 elements in 'firefox' match 'Kapat'; nothing sent. pass one id: [3] push button "Kapat" at 3440,0 [4] push button "Kapat" at 3440,22The position is shown when the window is in front; the window title is added when the candidates are in several windows. Read tools take the first:
get_textnotes it ([5] … (first of 2 matches):),wait_formatches on it,findlists them all. Matches are counted over one read of the app, made afresh for a refusal.Effect and
observeon input tools (act,menu,close_window,click,key,type_text,mouse,drag,scroll,set_text): withobserve(and by default foract,menuandclose_window) the reply ends with aneffect:line comparing the active window before and after the input (its title, the focused element, and on Linux the app's accessibility change events):changed,no visible change, orwindow changed to '<title>'(a dialog opened, a window closed). It waits for the app to react up tosettle_ms(default 150), returning as soon as it reported a change; the fingerprint itself costs about 1 ms. A canvas that draws without accessibility events may reportno visible change.observeadds a view in the same reply:focus(the focused element),tree(compact tree of the active window, or ofapp's, at most 6000 characters) orscreenshot(inline image). Withoutobserve, the other input tools still note, without waiting, when another window is in front after the input (; active window now '<title>') or came to the front since the previous input (; before it, '<title>' had come to the front: a dialog that opened after that reply); about 3 ms per input.What an input opened (Linux): after
act,menu,close_windowand the input tools givenobserve, and afterclick,key,mouseandtype_textwithout it, the reply lists the menus, popup menus, combo box lists, dialogs and alerts of allowed apps that opened, one line each, with their entries' ids, so the next call acts on them directly:opened: popup menu (8 entries): [51] "Kopyala" [52] "Yapıştır" [53] check menu item "Satır Sarma" {checked} … opened: dialog 'Değişiklikleri kaydet?': "Belge değiştirildi." [60] push button "Kaydet" [61] "Kaydetme" [62] "İptal"A role is named where it changes (a menu's
menu items and a list'slist items go without); a dialog's buttons come first, after its first label (the question it asks), those of its last button group (OK, Save, Cancel) before the others, then its other controls; its lists, tables, trees, documents and tool bars are not read. A KDE file dialog thatfile_dialogfills is named asopened: file dialog 'Open File' (use file_dialog), without entries. At most 12 entries and about 600 characters a line (the rest is counted:… +5 more), 3 surfaces a reply; a surface listed once in a call (byclose_window) is not read or listed again in its effect line. What showed after a reply (a dialog a button opened a few ms after the click) is listed by the next input's reply asbefore it, opened: …; what one reply listed is not again. A surface is listed only when its app's name reads as an allowed app's. The AT-SPI eventsobject:state-changed:showingandwindow:createsay what showed since the input: when none came, this costs nothing (no round trip); otherwise the role of each shown element is read (one round trip for all of them), and only menus, dialogs and lists under a combo box are read further. A right click (click,mouse), a click on a combo box or menu entry, and keys that usually open a menu or dialog (menu,shift+f10,f10,alt+a letter,alt+down,alt+f4,ctrl+o/s/p/w/q,ctrl+shift+s) wait up to 150 ms for something to show, ending as soon as it did; other inputs only list what showed already. When registering those events failed, those likely openers andact,menuandclose_windowlook at the active app's top-level windows instead, and a surface one such look listed is not read or listed again while it stays open. Not on Windows: UI Automation's roles do not name menus and dialogs this way (a dialog is awindow), so nothing is looked at or waited for there. Only menus already open are read: nothing here opens a menu through accessibility (Firefox can crash when one does).Failures say what is on screen: when
findfinds nothing, oract,click,focus,get_text,set_text,scroll,drag,wait_for,wait_idle,menu,key,type_textormousefails (or abatchstep fails and the batch does not end withscreenshotortree), the reply ends withnow: active window '<title>' [app]; focus: <element>, so the next call needs no screenshot to see why. Titles in such notes (now:,activated '…',active window now '…') are cut at 80 characters like element names, with the…after the closing quote so the quoted part still works aswindow, and a focus line leaves out thefocusedstate. Not for refusals by the allowlist, confirmations, an ambiguous target or the user taking over. A miss byqueryalso lists off-screen matches or the closest names with ids (seefind).Stale ids: every use of an
idreads the element's role and name again (one round trip). If the element is gone, or its role or name changed (the page was redrawn, the object path now belongs to another element), nothing is done and the reply isstale id N (was <role> "<name>", now ...); call tree/find again. For elements whose name follows their content or title (windows, tabs, documents, labels, status bars, text fields, combo boxes, editable elements) and for unnamed elements, only the role is compared.wait_for: waits until an element appears or disappears (checks every 50 ms, at most 10 minutes); for dialogs opened by a key or click, a long load or a build. A longer timeout is cut to 10 minutes and the reply says so.state(enabled, focused, checked, selected) waits until the element has that state (withgone, until it no longer does).windowinstead ofquerywaits for a window of an allowed app whose title or app name contains it to open, or withgoneto close (KDE and Hyprland window lists, re-read when AT-SPI reports a window change).wait_idle: waits until an app's accessibility tree has not changed forquiet_ms(default 300) and no browser page is still marked busy (loading from the network): after opening a URL or a link, use it instead of a fixedpause. The reply names the app's window title (a browser's page title); a timeout says when a page was still loading.pause: waits a fixed time (ms, at most 10 minutes); for waiting on time only, not on a visible change.windows: lists windows of allowed apps with position, size, state (active, maximized, minimized), output and virtual desktop in screen coordinates, after the screen layout (name, area, scale) and the virtual desktops (KDE: number, name, the current one; Hyprland: the regular workspaces and the monitor each is on) (KDE and Hyprland).move_window: moves, resizes, maximizes, minimizes or restores a window, and withdesktop(a number or name aswindowslists them) moves it to that virtual desktop (KDE: the window is then on that one only) or workspace (Hyprland: silently, the view stays; a new number makes the workspace); waits (at most 1 s) until the window has its new geometry and reports the real result (KDE and Hyprland; see the Hyprland limitations).close_window: closes the one window whose title or app name containsquery(several matches: an error listing them) as its close button does (KWincloseWindow(), Hyprlandclosewindow), so an app with unsaved changes still asks; the reply then says the window is still open and lists the app's dialog (the save prompt) with its buttons' ids (showing dialog '…': [60] push button "Save" [61] "Discard" …). Always needs the user's consent, as tools on theconfirm:list do (see Tiers, confirmation and password fields).menu: runs a menu bar entry by its labels in one call:pathis a list (["Dosya", "Farklı Kaydet…"]) or one string ("Dosya > Farklı Kaydet"). Labels are matched case-insensitively (Turkish dotted and dotless i alike), without&/_accelerator markers, a shortcut after a tab or a trailing ellipsis; an exact label wins, else a unique part of one. A label that is not there is also tried against KDE's English action names (file,file_save_as,edit_copy), so"File > Save As"works in a Turkish Kate (it saved two turns per task in the e2e benchmark). Withoutpathit lists the top level, and a path ending at a submenu lists that submenu's entries with ids (read-only where the app keeps closed submenus in the tree). Qt and GTK apps keep closed submenus' entries in the tree, so nothing is opened. KDE apps that replace the menu bar with a hamburger button ("Menüyü Aç", which has no accessibility action and no children) keep the hidden menu bar in the tree, and its entries work:menufinds it too (Kate, Dolphin). Firefox fills a menu only while it is open;menudoes not open Firefox menus itself (doing so through accessibility hung or crashed a fresh Firefox in tests), so open the menu withkeyfirst.launch: starts an allowed app (app, e.g.kate) with an optionalargslist (no shell; its.desktopfile first, else the program onPATH) and waits until its window opens (wait_ms, default 15 s, at most 60 s; 0 does not wait). Prints the window aswindowsdoes; also finds launches handed to an already running instance (firefox, kate). Window tracking works on KDE and Hyprland.Terminal windows:
windowsalso lists each terminal tab's pts device, foreground command and directory;activate_windowwithterminal: "pytest"brings forward the terminal running that command (or in that directory). A terminal with several tabs comes forward on whichever tab it shows. The terminal must be in the allowlist (e.g.konsole).file_dialog: fills in the file Open/Save dialog in front:path(absolute or~/…) is written into its file name field, the default button (Open, Save) is pressed and the dialog's closing is awaited (Qt 5's name field and Qt 6's editable combo box alike). Works on an allowed app's own KDE dialog and on the portal dialog (xdg-desktop-portal-kde) a browser opens for uploads and downloads; a portal dialog is filled only when the parent window KWin reports for it belongs to an allowed app, and a dialog without a reported parent is refused. The portal is not added to the allowlist; no other element of the dialog is reachable. Foraction: "open"the file must exist, for"save"its folder (KDE only).doctor: checks the setup in one report: version, allowlist, which running apps are allowed, the session, AT-SPI, the virtual keyboard and mouse, KWin scripting, screens and the screenshot path.key,type_text: keyboard. The reply names the element that has focus afterwards;type_textwarns when focus is not on an editable field (KDE). In abatch, a step whose focus is the one the previous report described saysfocus: [id] (unchanged)instead of describing it again. A menu or dialog the key opened is listed with ids (see "What an input opened").activate_window: brings a window to the front; fails when no window of an allowed app matches.windowon input tools (key,type_text,click,mouse,scroll,focus,drag,set_text,act,screenshot): a title or app-name substring, matched as byactivate_window. When the window in front is not a matching window of an allowed app, that window is activated first (the reply starts withactivated '<title>';), in the same critical section as the input, so no other call takes the focus in between; when it already is, nothing extra happens. Withoutwindownothing is activated.clipboard_get,clipboard_set: read or write the clipboard as text (Linux only). Because the clipboard can hold anything the user copied anywhere (passwords included), both work only while a window of an allowed app is active.clipboard_getreturns at mostlimitcharacters (default 20,000; the reply says when it was cut); if the clipboard holds an image or files rather than text, it only names the types, and it does not read content a password manager marked as a password (x-kde-passwordManagerHint).mouse,screenshot: fallback only.screenshotreturns the image inside the reply, shrinks it so its longer side is 1024 px by default (max_size) and reports which screen area it shows. The inline image is a JPEG (quality 80, full colour resolution, so small text stays sharp) when that takes at most three quarters of the PNG's bytes, else a PNG: a 1024x576 YouTube window took 132 KB instead of 670 KB, a Confluence page 94 KB instead of 246 KB; flat windows stay PNG. Image tokens depend on the size only (about width x height / 750: 786 at 1024x576), so the default size stays: at 896 px a 1080p window's 13 px text shrinks to 6 px. An inline screenshot leaves no file behind and names none;inline: falsesaves a PNG and returns only its path; givenimage_x/image_y,mouseconverts a pixel of the image to a screen coordinate (window position and display scaling, e.g. 125 %, included). Zoom:region: [x, y, w, h]in pixels of the last screenshot captures again and returns only that part, cropped before shrinking, at up to full resolution (max_sizestill applies);image_x/image_ythen refer to the zoomed image.batch: runs several tools in order in one call and stops at the first error; reduces Claude's number of turns. A step with"optional": true(typically await_for) may fail without stopping the batch: it is reported as[n] tool failed (optional): …and the remaining steps still run, so a final observation step (find,screenshot) is not lost. A step'sid/*_idargument"$N"is the element step N produced (find's first hit, wait_for's match, the target of a tool given one). A small procedure fits in one batch, with plain JSON control flow:"if": {"step": N, TEST}on a step runs it only when the test holds on earlier step N; otherwise it is reported as[n] skipped (if step N …). TEST is one of"ok": bool(the step succeeded),"found": bool(it succeeded and produced an element;findhad a match) or"contains": "text"(its reply or error, case-insensitive). A skipped step meets no condition."on_fail": [steps]on a step runs those steps (reported as[n.on_fail] …) only when the step failed; the batch then goes on. Not after a refusal by the allowlist (or a tier, or a password field), a confirmation the user declined or was not asked for, or the user taking over: then[n.on_fail] not run (refused). Such a refusal also ends a repeat, even in an optional step.{"repeat": {"steps": [...], "until": TEST, "max": 10}}runs its steps (reported as[n.k] …, last run only) until TEST holds on the last of them, checked after each run; it fails aftermaxruns (1 to 20, defaultor once it ran 10 minutes, and when a step in it fails that is not optional.
"$N"and conditions on a repeat mean its last step's result in the latest run. Its input steps withoutobserve(and any such step a condition tests) wait for the app to react, up to 150 ms (ending at its first change events), before their reply is read: the focus akeyreports is the one it moved to.
{"stop_if": {"step": N, TEST}}ends the batch there, successfully. Steps are numbered at the top level only; steps insiderepeatandon_failtake onlytool,argsandoptional(no nesting). The whole batch is checked before anything runs (unknown tools or keys, conditions and"$N"that do not name an earlier step or can never hold,maxout of range): a mistake fails it withnot run (nothing sent): [n] …. Its input lock, the takeover check, the allowlist andobservework per step as in a plain batch. Example, scroll Dolphin's view of a folder "Reports" (the view is named after the folder) until a file shows, then click it, once the view stopped moving:[{"repeat": {"steps": [{"tool": "scroll", "args": {"app": "dolphin", "query": "Reports", "role": "list"}}, {"tool": "wait_idle", "args": {"app": "dolphin", "quiet_ms": 150}}, {"tool": "find", "args": {"app": "dolphin", "query": "report.pdf"}}], "until": {"found": true}}}, {"tool": "click", "args": {"id": "$1"}}].
The initialize reply carries instructions (about 3 KB): the workflow,
the targeting rules shared by the tools above, the allowlist and which tools
to load together. Clients that load tool schemas on demand, such as Claude
Code, show it at session start before any schema is loaded; the per-tool
descriptions only point to it.
Allowlist
Re-read on every call; a default list is created on first run.
KDE and Hyprland:
~/.config/desktop-mcp/allow.txt, one AT-SPI application name per line (kate,firefox…). Window tools (windows,move_window,activate_window) match a window by the AT-SPI application name of its process (pid), so apps whose window class differs are found too (Ferdium:electron). Apps without accessibility match by window class (likexterm).Windows:
%APPDATA%\desktop-mcp\allow.txt, one program name per line without.exe(notepad,firefox,chrome…).
Rules:
Elements of apps not in the list cannot be read or acted on.
Keyboard and mouse input is sent only when the window in front belongs to an app in the list.
activate_windowonly brings windows of listed apps to the front.launchrefuses an app not in the list before starting anything.file_dialogfills in the dialog of the (unlisted) portal only when it was opened for a listed app; a dialog opened by another app is refused.
Use the apps tool to see the right names.
Tiers, confirmation and password fields
An app can be limited to a tier with app:tier; a plain name means full, so
files without tiers keep working unchanged:
firefox # full: everything
konsole:view # read tools only
dolphin:click # no typing
confirm: close_window, key:konsole
passwords: keepassxctier | allows |
|
|
| plus |
| plus |
The tier is checked against the app a call reaches: the target element's app
for element tools, the active window's app for keyboard and pointer input
(both for click, scroll and focus). An app listed twice gets the lower
tier; an unknown tier counts as view. A refusal names the tier.
confirm: lists tools (tool) or tools in one app (tool:app) that need
the user's consent per call. When the client supports MCP elicitation the
server asks the user through it; otherwise the call is refused unless it
carries confirm: true (the agent is told to ask the user first).
Password fields (AT-SPI role password text, UIA IsPassword) never show
their text in tree, find or wait_for, and get_text refuses them.
set_text, and type_text while one has focus, refuse them too unless the
app is on a passwords: line (passwords: all for every app) or the server
runs with DESKTOP_MCP_ALLOW_PASSWORDS=1. doctor shows the tiers, the
confirm list and the password setting.
Installation
Every platform needs Rust. After installing, open a new
Claude Code session and check with /mcp that the desktop server is
connected.
KDE Plasma (Wayland)
Requirements:
KDE Plasma 6, Wayland session
System packages (Arch):
at-spi2-core,libkscreen,wl-clipboard,spectacleKWin 6.8 or later for fast screenshots. On older KWin,
screenshotfalls back tospectacle; enabling KWin'sScreenShot2interface takes installing a.desktopfile by hand once (details in theSHOT_NOT_AUTHORIZEDcomment inrust/src/linux/kwin.rs)Write access for the user on
/dev/uinput(getfacl /dev/uinput)
git clone https://github.com/ahmetadakli/kde-desktop-mcp.git
cd kde-desktop-mcp/rust
cargo build --release
claude mcp add --scope user desktop -- "$PWD/target/release/desktop-mcp"Hyprland
Requirements:
A Hyprland session (the
movecursordispatcher)System packages (Arch):
at-spi2-core,wl-clipboard; recommended:wtype(typing without touching the clipboard),grim(fallback screenshots)A working AT-SPI bus in the session. Qt apps may need
env = QT_LINUX_ACCESSIBILITY_ALWAYS_ON,1in the Hyprland configuration.Write access for the user on
/dev/uinput
The installation commands are the same as for KDE. Start Claude Code from
inside the Hyprland session so HYPRLAND_INSTANCE_SIGNATURE reaches the
server.
Windows
Requirements: Windows 10 or 11. In PowerShell:
git clone https://github.com/ahmetadakli/kde-desktop-mcp.git
cd kde-desktop-mcp\rust
cargo build --release
claude mcp add --scope user desktop -- "$PWD\target\release\desktop-mcp.exe"Building for Windows on Linux also works (mingw-w64-gcc and
rustup target add x86_64-pc-windows-gnu, then
cargo build --release --target x86_64-pc-windows-gnu).
Speed: tools loaded up front
Claude Code defers MCP tools behind a tool search, which costs a model turn at
the start of every desktop task. The server marks the ten tools nearly every
task starts with (batch, find, act, activate_window, key,
type_text, wait_for, wait_idle, screenshot, apps) with
_meta: {"anthropic/alwaysLoad": true}, so Claude Code loads them with the
session (about 11.5k characters of schema); the others stay deferred. To load
every tool up front instead, add "alwaysLoad": true to the server's entry in
the MCP config.
Speed: skipping the permission prompt
Claude Code sends every MCP call that is not in its allow list to a prompt or,
in auto mode, to its safety classifier; that adds roughly 0.7–2.5 s per call.
If you trust all of the server's tools, add one rule to
~/.claude/settings.json:
{ "permissions": { "allow": ["mcp__desktop"] } }Testing
Unit tests need no real desktop (the pure part of the Windows logic runs on Linux too):
cd rust && cargo test --releasetools/mcp_smoke_test.py tests end to end on the real desktop over the real
MCP protocol (Linux, Kate): it opens the editor with a temporary file, finds the
element, refocuses it after focus moved elsewhere, types text with Turkish
characters through batch, tries rapid consecutive typing and key repeat,
saves with Ctrl+S, reads the file back from disk, launches Kate with launch,
and checks that the allowlist blocks an unknown app. It prints the duration of
every call. Only the Python standard library is needed. Do not touch the
keyboard and mouse while it runs.
python3 tools/mcp_smoke_test.py rust/target/release/desktop-mcptools/mcp_bench.py measures one or more servers with the same calls and
prints a table of median ms (the window must be open):
python3 tools/mcp_bench.py --app kate --window notes.txt \
--server "Rust=rust/target/release/desktop-mcp"End-to-end task benchmark
Tool calls take milliseconds; what makes a desktop task slow is the number of
model turns and the size of what the model reads. tools/e2e_bench.py measures
that on real tasks: it runs Claude Code headless (claude -p, only this
server's tools; built-in tools, the user's settings, plugins, hooks and skills
left out) once per task × model × server binary and records model turns,
desktop tool calls, wall time up to the final result split into startup,
model and tool time (calls of one turn that run at once count once), the
exit after the result on its own, reply characters and images, tokens, the
model actually used, and whether the task got done. default in --models
means Claude Code's own default model, not the one in your settings. The tasks
in tools/e2e_tasks.json each open their own window on a fresh temp file
(Kate: append a line and save, Save As to a new path, search and replace, read
a value; Firefox: fill and submit a local form in a new window; Dolphin:
create a folder), state the goal as a user would, check the
outcome on disk, in the final answer or with a read-only query, and close only
what they opened (the PID their setup recorded, or the one window carrying the
run's unique tag).
python3 tools/e2e_bench.py --list
python3 tools/e2e_bench.py --tasks kate_append,kate_replace --models default,claude-haiku-5-5 \
--server "old=/path/to/old/desktop-mcp" \
--server "new=rust/target/release/desktop-mcp" --repeat 3 --results /tmp/e2eIt prints a Markdown table of medians and writes results.json (with every
tool call, its time and reply size), table.md and each run's raw stream to
--results (default: a new temp dir; never inside the repository). The agent
types into the windows the tasks open (each run makes its window the active
one first): do not use the keyboard or mouse while it runs. Firefox and
Dolphin open the task's window in their running instance; close your own
windows of them first, or pass --exclusive to skip those runs (each run
notes other open windows of its app). Ctrl+C stops the agent with its whole
process group before cleanup. python3 -m unittest tools/test_e2e_bench.py
tests the measuring and the process handling without a desktop.
Measurements: KDE speed-up
tools/mcp_bench.py, KDE Plasma 6.8, Kate; median of 5 rounds (ms). "Before"
is 2f2e5df (the version that removed Python), "After" this version:
Operation | Before (ms) | After (ms) | Speed-up |
| 4.4 | 3.3 | 1.4x |
| 3.8 | 4.2 | 0.9x |
| 8.5 | 10.3 | 0.8x |
| 12.0 | 10.0 | 1.2x |
| 10.1 | 9.9 | 1.0x |
| 0.3 | 0.3 | 0.9x |
| 19.5 | 6.4 | 3.0x |
| 34.9 | 2.9 | 12x |
| 103.5 | 12.1 | 8.5x |
| 78.1 | 11.1 | 7.0x |
| 29.3 | 4.9 | 6.0x |
| 258.8 | 12.1 | 21x |
The ~2 ms in activate_window is reading the active window title so act can
report a window change. The real gain lies beyond milliseconds: short output,
targeting with app + query in one call, wait_for and batch reduce the
number of Claude's turns and the text it reads.
Measurements: compared with the old Python version
The server used to be written in Python; its last state is commit 4cd2632 in
the git history. The Rust version offers the same tools with the same output:
1,499 lines of output on KDE and 2,025 on Hyprland were compared with the
Python version, with no difference. The values were taken with
tools/mcp_bench.py applying the same call sequence to both servers in turn;
median of 5 rounds (ms, in a Kate window, same machine).
KDE Plasma 6 (Wayland):
Operation | Python (ms) | Rust (ms) | Speed-up |
| 400.5 | 2.2 | 181x |
| 48.8 | 4.2 | 11.6x |
| 22.7 | 7.4 | 3.1x |
| 66.6 | 11.5 | 5.8x |
| 40.8 | 9.4 | 4.3x |
| 5.0 | 0.3 | 15x |
| 61.3 | 19.4 | 3.2x |
| 197.0 | 35.6 | 5.5x |
| 339.2 | 102.8 | 3.3x |
| 165.5 | 78.4 | 2.1x |
| 66.4 | 28.8 | 2.3x |
| 269.0 | 254.3 | 1.1x |
Hyprland 0.56 (nested session inside KDE):
Operation | Python (ms) | Rust (ms) | Speed-up |
| 394.5 | 2.2 | 178x |
| 145.6 | 4.3 | 34x |
| 225.0 | 3.1 | 73x |
| 120.9 | 18.5 | 6.5x |
| 75.9 | 16.1 | 4.7x |
| 4.5 | 0.3 | 13x |
| 60.5 | 19.2 | 3.1x |
| 28.6 | 11.1 | 2.6x |
| 319.5 | 77.7 | 4.1x |
| 168.7 | 78.0 | 2.2x |
| 69.1 | 28.3 | 2.4x |
| 38.1 | 24.2 | 1.6x |
Windows (Notepad inside Wine 11.20; not a real Windows measurement):
Operation | Python (ms) | Rust (ms) | Speed-up |
| 1998.6 | 62.8 | 32x |
| 43.0 | 1.4 | 32x |
| 5.9 | 0.9 | 6.4x |
| 54.8 | 19.3 | 2.8x |
| 7.1 | 1.1 | 6.5x |
| 55.6 | 30.5 | 1.8x |
| 82.3 | 8.3 | 9.9x |
Wine's UI Automation client does not support cache requests, tree walking and
most properties (E_NOTIMPL), so tree, find, get_text, focus and
click do not work under Wine on either server; the apps and
activate_window figures in the table are for returning without finding a
window. Most of the difference in the input steps comes from Rust's shorter
waits.
The gap grows with larger trees: a 2,000-element tree of Dolphin including
hidden elements takes 507 ms in Python and 48 ms in Rust. screenshot time is
mostly the spectacle and grim programs themselves.
In nested Hyprland, Ctrl/Alt shortcuts from uinput get lost on their way through the outer KWin (on both servers), so no shortcut tests were run there. In a real Hyprland session uinput goes straight to Hyprland.
Known limitations
KDE:
The server turns on the accessibility bus at run time. Apps started before that need one restart.
Chromium, Chrome and Electron apps show their tree only when started with
--force-renderer-accessibility.type_textpastes through the clipboard into fields other than Qt's multi-line editors (such as Kate). It first saves the clipboard text and puts it back once the target app has read the paste (seen through Wayland data-control). The typed text does not enter Klipper's history (it is offered with the password hint). The clipboard is not put back when it held an image, files or text over 4 MiB, nor when the app does not read the paste within 1 s (so that a late reader does not paste the user's clipboard); the reply says so in both cases. Rich text (HTML) comes back as plain text only.Direct insertion differs slightly from pasting: replacing selected text is two undo steps, with multiple cursors only the primary cursor gets the text, overwrite mode and Kate's vi mode are bypassed, and auto-indent and bracket completion do not run as they would on a paste.
Hyprland:
Client-side shadows of GTK apps can shift
clickandfocusclicks by a few pixels.Two windows of the same process with the same title cannot be told apart.
Without
wtype,type_textpastes through the clipboard as on KDE and puts the clipboard text back afterwards.Hyprland has no minimize:
move_windowwithstate: "minimize"moves the window to the special workspacespecial:minimized, andwindowsreports windows on a special workspace no monitor shows as minimized.restorebrings it back to the workspace its monitor shows (not necessarily the one it came from).Maximize is Hyprland's maximized fullscreen mode (
fullscreenstate 1 1);windowsreports any fullscreen mode as maximized. Hyprland applies fullscreen states only to the focused window, somaximizeandrestoreof a maximized window also focus it.Moving or resizing a tiled window makes it floating first (
setfloating); it stays floating afterwards.Hyprland 0.57 takes only Lua dispatchers over its socket (
hyprctl dispatch Xbecamehl.dispatch(X)). The server notices the first Lua error and from then on sends the Lua form of every dispatcher it uses (hl.dsp.focus,hl.dsp.window.move/resize/float/fullscreen_state/close,hl.dsp.cursor.move); verified against a nested Hyprland 0.57. Named workspaces have no number there;windowslists them by name.
Windows:
Input cannot be sent to windows running as administrator from a server that is not (Windows UIPI).
Microsoft Store apps may show up as
applicationframehost.Key names follow the US keyboard layout positions; use
type_textfor text.In very large windows (browsers, Word)
treeandfindcan be slow because they fetch the whole subtree at once.
All:
Games and canvas-based interfaces mostly show up empty in the accessibility tree.
Security
The server runs with your user's permissions. The allowlist is a safety layer, not operating-system level isolation. Think twice before adding terminals, the file manager or system settings to the list.
License
MIT
Inline screenshots are encoded with the jpeg-encoder crate; this software
is based in part on the work of the Independent JPEG Group.
This server cannot be deployed
Maintenance
Related MCP Connectors
Eyes and hands on real Windows PCs — observe, click, type via Glasswarp API.
Automate 1,000+ services from any MCP-compatible AI agent: build Applets, run actions and queries.
Connect ChatGPT and Claude to your Mac, Windows or Linux with secure MCP remote access.
MCP server for your apps' tools and custom tools, plus hosted AI agents and approval-gated workflows
Related MCP Servers
- AlicenseAqualityCmaintenanceAllows AI clients to see and control Windows 10/11 desktops via MCP, with screenshots, UI Automation, Chrome CDP, keyboard/mouse, and terminal using semantic element targeting.301,635 npmMIT
- AlicenseNot gradedqualityDmaintenanceEnables MCP clients to control macOS via accessibility and screen recording, providing tools to list apps, observe UI, click, type, press keys, and scroll.MIT
- AlicenseNot gradedqualityCmaintenanceEnables MCP clients to control a Linux/X11 desktop like a human: see the screen, move the mouse, click UI elements via the accessibility tree, type text, and manage windows.MIT
- AlicenseNot gradedqualityCmaintenanceEnables MCP-capable AI agents to control Windows desktops through UI Automation element trees, native screenshots, app launching, batched action sequences, smart waits, and safety gates using normalized coordinates.MIT