list_windows
List all Hyprland windows: id (address), class, title, workspace, output, global geometry, floating/focus/visibility. Use the `id` for focus/screenshot.
Takes no parameters.

com.smirnovlabsPublished by smirnovlabs.comv0.2.0Updated Nov 7, 2026
Hyprland/Wayland computer-use MCP: grim screenshots + uinput click/type + hyprctl targeting
The publisher's own server: Ynab Mcp Server
Hyprland/Wayland computer-use server: screenshots, mouse and keyboard input, window control, plus tmux and Chrome DevTools channels.
Desktop only via STDIO. STDIO servers start a local process, so they need the SourceWeft desktop host.
Follow the launch instructions in the repository.
[Status: stable · maintained] [CI] [PyPI] [Python] [License: MIT]
A Hyprland/Wayland computer-use MCP server. It gives an MCP client (Claude Code, etc.) the ability to see your desktop, understand it via the Hyprland tree, and drive it with mouse + keyboard. It runs locally over stdio.
Why this exists: generic Linux computer-use servers are blind to the compositor —
they hand the model a giant multi-monitor screenshot and hope its pixel guesses
land. This server crops to one output (or one window) with grim, downscales to
the vision-API budget, and exposes hyprctl's tree so the model targets windows
by identity, not luck. Coordinates are always given in the screenshot the model
was just shown; the server maps them back to global layout pixels.
Built for Wayland/Hyprland: grim (capture) + a self-owned uinput device
(input) + hyprctl (tree/control) + wl-clipboard. It is the Wayland successor
to i3-computer-use-mcp (X11).
Window ids are Hyprland addresses (0x…) from list_windows — not integers.
See
Act
Control Hyprland (needs a Lua config, see Requirements)
Work off-screen (no window, no focus stolen)
The Hyprland tree uses global layout pixels spanning all monitors. Screenshots are cropped + downscaled, so every screenshot records a mapping frame (region origin + per-axis scale). Pointer tools accept coordinates in the screenshot's pixel space and translate them:
Practically: take a screenshot (it returns a frame_id), then call
click(x, y, frame_id=…) with the pixel you see in that image. Pointer tools
reject a stale or unknown frame_id rather than guessing against the wrong one.
Without a frame_id, they use your latest screenshot.
The uinput pointer positions absolutely: global pixels map to the device's abs
axes via the layout extent read from hyprctl monitors (verified ~1px).
hyprland.lua, Hyprland 0.55+) for the window-control tools.
These tools go through hyprctl eval and Hyprland's Lua hl.dispatch API:
focus_window, launch, goto_workspace, move_window_to_workspace,
set_floating, layout_message, run_dispatch, plus type_text/key with a
con_id and move_mouse(follow=True). Everything else works with any config:
screenshots, pointer and keyboard input, the window tree, the clipboard, and the
tmux/task/browser channels./dev/uinput for mouse and keyboard input (see below).uv.WAYLAND_DISPLAY,
XDG_RUNTIME_DIR and HYPRLAND_INSTANCE_SIGNATURE from the environment. MCP
clients launched from your desktop pass these through.The server shells out to a handful of CLI tools. screenshot needs grim, the
clipboard tools need wl-clipboard, and everything reads/drives Hyprland through
hyprctl; tmux and a Chromium/Chrome are only needed for the optional terminal
and browser channels.
Arch, in one go (Hyprland assumed already installed):
Input uses a self-owned uinput device, so the process needs read-write on
/dev/uinput. It needs no root and no ydotoold. Run getfacl /dev/uinput. If it
lists your user with rw (typically through a uaccess udev rule), you're set. If
not, add a rule that grants access to the user logged in at the seat:
The Python deps (mcp, pillow, evdev, websocket-client) are synced by uv
on first launch.
Miss one and the matching tool says so: calling screenshot without grim
returns "grim not found; install it …" straight to the model, so it can tell you
what to install instead of failing on a cryptic traceback.
It's a local stdio server. The client starts it on demand, and you don't need a clone.
Claude Code:
Clients with an mcpServers JSON config (Cursor, Gemini CLI, Cline, …):
Codex CLI (~/.codex/config.toml):
From a local checkout: the wrapper syncs deps on first launch. When a shared server is running, the wrapper connects to it.
Reconnect your client (in Claude Code: /mcp → reconnect) to pick up the server.
Smoke-test the plumbing first (read-only, no clicks):
By default each MCP client session starts its own hypr-cua. From a checkout you can
run one shared server per desktop login instead. A session then connects in
about 20 ms and costs about 17 MB instead of about 94 MB. bin/hypr-cua-mcp
connects to the shared server when it runs. When it doesn't, the wrapper starts a
stdio server for the session, so the registration stays the same.
Install the units. Run this in the checkout:
Start the socket from your Hyprland startup, so each new server gets the current
session's variables. In hyprland.lua, inside your startup handler:
Run the same command in a terminal once to start it without logging out.
Reconnect your MCP sessions (in Claude Code: /mcp → reconnect).
To upgrade, run git pull && uv sync && systemctl --user restart hypr-cua.service.
Open sessions reconnect by themselves. A call that was running during the restart
fails once with a message that it may have completed. The restart also ends
hypr-cua's tmux sessions and tasks.
Sessions share the server's browser. Its HYPR_CUA_CDP_* settings come from the
service's Environment= lines, not from the client. Screenshots stay per session:
the server rejects another session's frame_id as stale.
The cdp_* tools drive Chrome or Chromium tabs over the DevTools protocol, so
browser work needs no window focus. They never open your main profile.
The first cdp_* call picks a browser, and later calls keep it while it stays up:
cdp_targets reports which one is in use, "browser": "automation" or
"private". All sessions of one server share that browser, its tabs and its
cookies.
For logins that persist, start a dedicated automation profile on port 9222. From a
checkout, bin/hypr-cua-chrome does that. Otherwise run:
Log in only to what you're happy to automate. Any local process that can reach a DevTools port can drive that browser, and that includes the private one.
cdp_open and cdp_navigate wait for the page's load event, and for a CSS
selector if you pass wait_for. A page still loading at timeout (default 30 s)
comes back with "timed_out": true instead of an error. cdp_read takes a
selector and max_chars. cdp_screenshot returns an image of a tab's viewport.
Set them in your client's server config. In Claude Code:
claude mcp add hypr-cua -e HYPR_CUA_CDP_AUTOLAUNCH=0 -- uvx hypr-cua-mcp. In JSON
configs, add an "env": {…} block next to command.
follow_mouse=1 caveat: Hyprland keyboard focus tracks the pointer, so
focus_window(address) is best-effort — the reliable way to focus a window for
typing is to click it via screenshot coordinates. type_text(con_id=…)
raises rather than typing into an unverified window.docs/gotchas.md.type_text skips any other character. For anything
else, write_clipboard and paste.move_mouse(follow=True).cdp_click / cdp_type inject JavaScript, so pages that insist on trusted input
events won't react.Details, plus the task runner's edge cases:
KNOWN-ISSUES.md.
Stable and maintained. hypr-cua-mcp does what it set out to do, so the commit log is quiet on purpose. Expect bug fixes, Hyprland-compatibility updates and dependency bumps, not a stream of new features. A quiet repo here means done, not abandoned. Issues and PRs still get answered.
Bug reports and fixes are welcome; for anything bigger, open an issue first. See
CONTRIBUTING.md,
and AGENTS.md
for setup, tests, and the Hyprland conventions that bite.
On PyPI as hypr-cua-mcp and in the MCP
registry as com.smirnovlabs/hypr-cua-mcp (manifest:
server.json).
mcp-name: com.smirnovlabs/hypr-cua-mcp
MIT — see LICENSE.
Source: README.md at commit fb8fa60
List all Hyprland windows: id (address), class, title, workspace, output, global geometry, floating/focus/visibility. Use the `id` for focus/screenshot.
Takes no parameters.
List active outputs (monitors) with their global geometry, scale, workspace.
Takes no parameters.
Screenshot one output (monitor); defaults to the focused output. Pass an output name from get_outputs (e.g. "DP-1") to target another monitor. Returns the image plus a frame_id — pass that frame_id to click/move/etc.
outputFull-resolution screenshot cropped to a single window (by address). NOTE: this crops the layout grab by the window's *geometry*. The window must be on its monitor's active workspace (else the region shows whatever IS there); a floating window sitting on top can also occlude it. A warning is added to the caption when the window isn't focused.
con_idrequiredClick at (x,y) in the pixels of screenshot `frame_id` (default: the last screenshot). button: left|middle|right. Errors if no screenshot exists yet or the frame_id is stale.
xrequiredyrequiredbuttonframe_idDouble-click at (x,y) in screenshot `frame_id`'s pixel space.
xrequiredyrequiredbuttonframe_idMove the pointer to (x,y) in screenshot `frame_id`'s pixel space. follow=True mimics a physical mouse's hover-focus: after moving, give keyboard focus to the window under the cursor. The synthetic pointer alone does NOT trigger Hyprland's own follow_mouse on a warp, so focus is set explicitly. No-op focus if the cursor lands on empty space or a bar. (A plain click already focuses what it hits — use follow to focus by hovering, without clicking.)
xrequiredyrequiredfollowframe_idDrag from (x1,y1) to (x2,y2), coords in screenshot `frame_id`'s pixel space.
x1requiredx2requiredy1requiredy2requiredbuttonframe_idScroll at (x,y) (screenshot `frame_id` pixel space). direction: up|down.
xrequiredyrequiredamountframe_iddirectionGive keyboard focus to a window (by address). Raises if focus can't land.
con_idrequiredType text into the focused window. If con_id (address) is given, focus it first and fail (no keystrokes sent) if that focus can't be verified. NOTE: US-ASCII only (uinput has no unicode-compose path); unmapped characters are skipped, and embedded newlines are Enter keypresses.
textrequiredcon_idPress a key/chord (e.g. 'ctrl+s', 'Return', 'alt+Tab'). If con_id (address) is given, focus it first and fail if that focus can't be verified.
keysrequiredcon_idCurrent global pointer location.
Takes no parameters.
Read the text of a Wayland selection: 'clipboard' (Ctrl-C / Ctrl-Shift-V) or 'primary' (select / middle-click). Returns '' if empty.
selectionSet a Wayland selection ('clipboard' or 'primary') to text, then paste with key('ctrl+v' or 'ctrl+shift+v') or a middle-click. Returns only a length summary — never the value. secret=True marks the value for redaction in this server's action log (not built yet) and keeps it out of the return; the text still transits this tool call, so it appears in the MCP client's transcript.
textrequiredsecretselectionList the copilot's tmux panes — session, pane, running command, size, state. They live on a dedicated 'hypr-cua' tmux socket, isolated from your real (default-socket) sessions. Detached = no window, no focus, zero screen footprint — the way to run terminal work on a non-visible workspace.
Takes no parameters.
Create a detached copilot session `name` running a shell (or `command`). No window, no focus. Drive it with tmux_run/tmux_keys, read it with tmux_read.
namerequiredcommandType a shell command line into session `target` and press Enter. Read the output afterward with tmux_read (the shell persists between calls).
targetrequiredcommandrequiredSend interpreted tmux keys to `target` (e.g. 'C-c' to interrupt, 'Up', 'Escape', 'q') — for TUIs / control sequences, not literal command text.
keysrequiredtargetrequiredCapture session `target`'s terminal text (last `lines` incl. scrollback).
linestargetrequiredKill copilot session `target` (only touches the isolated 'hypr-cua' socket).
targetrequiredList the copilot browser's tabs as JSON {browser, tabs: [{id, title, url}]}. `browser` is "automation" (the Chrome on port 9222) or "private" (a headless Chrome hypr-cua started because nothing answered on 9222; it stops after 10 idle minutes, and its tabs and cookies go with it). Use a tab's `id` with the other cdp_* tools.
Takes no parameters.
Open a new tab at `url` and wait for it to load, plus for CSS selector `wait_for` if given. Returns JSON {id, url, title, ready_state, waited_ms, timed_out}. A page still loading at `timeout` (1 to 300 s) returns timed_out=true instead of failing.
urltimeoutwait_forNavigate a tab (by id from cdp_targets) and wait like cdp_open. Returns the same JSON.
urlrequiredtargetrequiredtimeoutwait_forEvaluate a JavaScript expression in a tab and return its value as JSON. Promises are awaited. `timeout` (1 to 300 s) bounds the whole call. A thrown JS exception is an error.
targetrequiredtimeoutexpressionrequiredRead the visible text (innerText) of a tab, or of the first element matching CSS `selector`, as JSON {title, url, text, chars_total, truncated}. The text is cut to max_chars inside the page.
targetrequiredselectormax_charsScreenshot a tab's viewport in the copilot browser (no window focus needed). The image has no frame_id: act on the page with cdp_click or cdp_eval, not with the desktop pointer tools.
targetrequiredClick the first element matching a CSS selector in a tab (by id). Errors if nothing matches the selector.
targetrequiredselectorrequiredType text into the element matching a CSS selector in a tab (by id): sets its value and dispatches input/change events. Returns a length summary — not the text.
textrequiredtargetrequiredselectorrequiredClose a tab (by id) in the copilot browser.
targetrequiredLaunch `command` as a background TASK in a hidden copilot tmux session — no window, no focus, zero screen footprint. The command is wrapped so completion is detectable (a `__TASK_DONE__:<exit_code>` sentinel), so you can poll it with task_status. Returns the task's initial {name, state} as JSON. Names must be unique and free of ':', '.', '/', and whitespace.
namerequiredcommandrequiredCheck a task: JSON {name, state, exit_code?, tail}. state is 'running' (still going), 'done' (finished — `exit_code` 0 = success, non-zero = failure), or 'missing' (no such task). `tail` = that many last lines of its output.
namerequiredtailList all tasks with their state (running | done+exit_code) as JSON — the copilot tmux sessions started via task_start (plain tmux sessions excluded).
Takes no parameters.
Send input to a running task — e.g. answer a prompt it's blocked on. Default types `text` + Enter; enter=False sends interpreted keys ('C-c', 'Up', 'Escape') for control sequences. (Detecting that a task is waiting on input is best-effort: read task_status's tail and answer accordingly.)
namerequiredtextrequiredenterStop a task by killing its hidden tmux session (only touches the isolated 'hypr-cua' copilot socket).
namerequiredLaunch an app via Hyprland's exec dispatcher. Opens on the current workspace — pair with goto_workspace or move_window_to_workspace to place it.
commandrequiredSwitch the focused monitor to a workspace (created if absent). Numeric names match by number; other names are matched literally.
workspacerequiredMove a window (by address) to a workspace without following it.
con_idrequiredworkspacerequiredEnable/disable floating for a window (by address).
con_idrequiredenableSend a message to the active layout engine (dwindle/master), e.g. 'togglesplit', 'swapsplit', 'orientationnext'. (Hyprland has no per-window splith/tabbed/stacking like i3.)
messagerequiredEscape hatch: run a raw Hyprland dispatcher as the Lua expression passed to hl.dispatch(...), e.g. 'hl.dsp.window.fullscreen({})' or 'hl.dsp.window.move({ direction = "l" })'. `exit` is blocked.
dsp_exprrequired90% of the applicable checks met
How complete this listing is, computed from the registry entry, the endpoint and public sources. Not a rating of the server and not a statement that it is safe or working today: reachability is a dated check, and no installs or reviews go into the score.
Declare the credential headers in the registry entry, or keep the endpoint answering the probe so its auth can be classified.
global_x = region.x + screenshot_x / scale_xglobal_y = region.y + screenshot_y / scale_ysudo pacman -S grim wl-clipboard uv tmux chromiumecho 'KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"' \ | sudo tee /etc/udev/rules.d/60-hypr-cua-uinput.rulessudo udevadm control --reload && sudo udevadm triggerclaude mcp add hypr-cua -- uvx hypr-cua-mcp{ "mcpServers": { "hypr-cua": { "command": "uvx", "args": ["hypr-cua-mcp"] } }}[mcp_servers.hypr-cua]command = "uvx"args = ["hypr-cua-mcp"]claude mcp add hypr-cua -- /path/to/hypr-computer-use-mcp/bin/hypr-cua-mcpuvx hypr-cua-mcp --selftestuv syncmkdir -p ~/.config/systemd/usercp contrib/systemd/hypr-cua.socket ~/.config/systemd/user/sed "s|@CHECKOUT@|$PWD|" contrib/systemd/hypr-cua.service > ~/.config/systemd/user/hypr-cua.servicesystemctl --user daemon-reloadhl.exec_cmd("systemctl --user import-environment DISPLAY WAYLAND_DISPLAY HYPRLAND_INSTANCE_SIGNATURE XDG_CURRENT_DESKTOP; systemctl --user stop hypr-cua.service; systemctl --user restart hypr-cua.socket")chromium --user-data-dir="$HOME/.local/share/hypr-cua-chrome" \ --remote-debugging-port=9222 --no-first-run --no-default-browser-check