bad-ass-mcp
bad-ass-mcp
Every other tool needs a browser or a vision model. This one reads what the OS already knows.
A cross-platform MCP server for desktop GUI automation — driven by accessibility APIs, not screenshots.
Works with Claude, Codex, Gemini, Ollama, or any OpenAI-compatible client that supports MCP.
bad-ass-mcp recording itself interacting with MarkTheCrab
Why
Most AI desktop automation tools work by taking screenshots and asking a vision model "what do you see?" That's slow, expensive, and fragile.
Every major OS exposes a structured accessibility tree — the same one used by screen readers — that describes every button, text field, combo box, and menu in every running application. bad-ass-mcp speaks that language directly.
macOS: AXUIElement via PyObjC
Linux: AT-SPI2 via
gi.repository.AtspiWindows: UI Automation via
comtypes
Actions fire on control objects directly, so the target window doesn't need focus. The user can keep working while automation runs in the background.
Related MCP server: openowl
Tools
Tool | Description |
| List all visible application windows |
| Full accessibility tree for a window as nested JSON |
| Find elements by role and/or name |
| Click / invoke an element (foreground-independent); |
| Click at absolute screen coordinates — fallback for webview/canvas UI; takes |
| Drag-and-drop with real pointer events, between coordinates or element handles |
| Type text into a field (SetValue → EditableText → key injection) |
| Select an option in a combo box or list (a11y items → SetValue → keystrokes), verified by reading the value back |
| Get the current value or text of an element |
| Wait until a window matching a pattern appears |
| Wait until an element exists and optionally has a given state |
| Capture a PNG (write to |
| Start recording the screen (or a specific window) |
| Stop recording and export as GIF (short clips) or MP4 (long sessions, no re-encode) |
| Resolve semantic names → live handle IDs (one call per session) |
| Execute a list of actions server-side — no per-action round-trips |
Field notes: when the tree isn't enough
Observed driving large native apps (a game editor, a DAW) on Windows:
Context menus don't exist in the a11y tree until they're open, and nothing in UIA opens them.
click(handle, button="right")orclick_at(x, y, button="right")does; the menu is then a readable window.Double-click targets (list rows that open an editor) often expose only a Select action, which highlights the row and stops. Use
clicks=2.Tab strips can behave the same way: a11y Select highlights the tab without switching the page.
clicknow falls back to a real pointer click when no pattern takes, which switches it.Stock Win32 combo boxes are unreliable through UIA: the item list only exists while dropped down, and
SetValuecan succeed on one combo and silently no-op on the next one in the same dialog.select_optionends with a keystroke strategy (focus, type the item, Return) and reads the value back, sook: falsemeans it really didn't change.Custom-drawn panels (owner-drawn frames, canvas views) show up as empty panes. Only the app's native menus and standard file dialogs are readable; everything else is
screenshot+click_at.Mislabelled controls. Some dialogs attach each label to the neighbouring control (a combo named "Voice Type" is really the one above it, and the real one has no name). When names look off by one, confirm with
get_valueor pick by position inget_treeinstead of by name.Stock message boxes are fully readable.
wait_for_window("Warning")thenclickon "Yes to all" works as-is.SetValue skips the app's event handlers. Setting a filter box's text won't re-run the filter; press a key or click elsewhere and back to trigger it.
Never send bare keys into an editor without a focused dialog. In most DAWs and NLEs, single keys are editor shortcuts and will edit the project.
Long demos:
stop_recordingto a.mp4path keeps the raw capture (seconds to finish); GIF transcodes and downscales, fine for under a minute.
Webviews: waking lazy accessibility trees
Chromium-based apps (Electron, CEF, and every Chromium browser) have a
full accessibility tree — they just don't build it until an assistive
technology announces itself. Until then the window looks canvas-only:
get_tree returns nothing and screenshot-clicking is the only option.
bad-ass-mcp announces itself. When a window's a11y tree comes back empty and the process looks like a webview, the backend performs the platform's "a screen reader is here" handshake, waits a beat, and re-probes before giving up:
Linux: sets
org.a11y.Status.ScreenReaderEnabledon the session D-Bus at server start so Chromium/Electron apps launched afterwards enable accessibility from birth. Windows that stay hollow (an application+frame whose phantom child fetches asNone) are detected via the AT-SPI toolkit name and reportedaccessible: false: the app either launched before the flag went up or opts out on its own. Restarting the app with the server running should pick the flag up per Chromium's documented behavior, but note that is not yet verified here: the only Linux app live-tested so far (flatpak Vivaldi) ignores the flag entirely, its own first-run assistive-technology toggle included, and only produces a tree when launched with--force-renderer-accessibility.macOS: sets
AXManualAccessibilityon the app (Electron's documented switch), falling back toAXEnhancedUserInterface(what VoiceOver sets, watched by plain Chrome/CEF). The attribute set only succeeds on apps that support it, so it's a free no-op elsewhere.Windows: sets the system
SPI_SETSCREENREADERRUNNINGflag, then queries theChrome_RenderWidgetHostHWNDchild window — Chromium hangs its DOM tree off that renderer child, not the top-level HWND (which only shows empty panes).get_tree/find_elementsare routed through the renderer child automatically.
One wake attempt per process per server run. If list_windows still
reports accessible: false after this, the window is genuinely
canvas-only (custom OpenGL/Vulkan surface, immediate-mode toolkit
without AccessKit) — fall back to screenshot + click_at.
The tradeoff: a woken app spends some memory/CPU maintaining its tree, which is exactly why Chromium lazy-loads it. The wake targets the app you're automating, not the whole desktop (the Linux flag is technically session-global, but only a11y-aware apps respond, and only on demand).
Installation
Requirements: Python 3.11+, ffmpeg + gifsicle for recording (optional)
# macOS
pip install 'bad-ass-mcp[macos]'
# Linux — install system PyGObject + AT-SPI bindings first, then:
pip install bad-ass-mcp
# Windows
pip install 'bad-ass-mcp[windows]'Linux: PyGObject and AT-SPI are not on PyPI, and the backend shells out to xdotool, xprop, and ImageMagick's import (or KDE's spectacle) for clicks and screenshots. Install them from your distro:
# Debian/Ubuntu
sudo apt install python3-gi gir1.2-atspi-2.0 at-spi2-core xdotool x11-utils imagemagick
# Arch
sudo pacman -S python-gobject at-spi2-core xdotool xorg-xprop imagemagick
# Fedora
sudo dnf install python3-gobject at-spi2-core xdotool xprop ImageMagickThen make sure AT-SPI is enabled (most desktop environments — GNOME, KDE, XFCE — have it on by default):
# Check
gsettings get org.gnome.desktop.interface toolkit-accessibility
# Enable
gsettings set org.gnome.desktop.interface toolkit-accessibility trueIf you're using a venv, create it with --system-site-packages so it can see the distro-installed gi:
python -m venv --system-site-packages venvWindows: Elevated (admin) applications can only be automated from an elevated Python process. No special permissions are needed for normal apps.
Register with Claude Code
claude mcp add bad-ass-mcp --scope user -- bad-ass-mcpOr manually in ~/.claude.json:
{
"mcpServers": {
"bad-ass-mcp": {
"type": "stdio",
"command": "bad-ass-mcp"
}
}
}Usage
list the windows on screen
→ [{ "id": "12345", "name": "Firefox", ... }]
find the search box in window 12345 and type "hello"
→ find_elements(window_id="12345", role="entry")
→ type_text(handle_id="...", text="hello")Electron/CEF apps get their lazy accessibility tree woken automatically (see Webviews above). For apps that still don't expose a clean tree, type_text falls back to platform-native key injection automatically (AT-SPI on Linux, Quartz events on macOS, PostMessage on Windows).
Architecture
bad_ass_mcp/
├── server.py # FastMCP tool definitions
├── types.py # WindowInfo, ElementHandle, ActionResult, StaleHandleError
└── backend/
├── base.py # Abstract DesktopBackend interface
├── linux.py # AT-SPI2 + xdotool
├── macos.py # AXUIElement + Quartz
└── windows.py # UI Automation + ctypes Win32The abstract backend interface means adding a new platform is just implementing one class. The full contract-test suite runs against every backend via FakeBackend.
Releasing
Tag a version and the release workflow builds the sdist and wheel, publishes them to PyPI via trusted publishing, and attaches them (plus the PyInstaller bundles) to the GitHub release:
git tag v1.4.0 && git push --tagsLicense
MIT
Available Tools
17 toolsclickA
Click / invoke an element. Get handle_id from find_elements() or get_tree().
A plain left single click invokes the element's accessibility action (foreground-independent, does not steal focus). If the element exposes no actionable pattern, it falls back to a real pointer click at the element's center, so custom-drawn panels and tab strips that only highlight on a11y Select still get activated.
button="right" opens context menus; clicks=2 is a real double-click (opens list rows / tree items whose a11y invoke only selects them). Both always use real pointer events at the element's center, so make sure the window is on top for those.
Inputs: handle_id (required), button, clicks.
click_atA
Click at absolute screen coordinates (top-left origin, in points).
button: "left" (default), "right" (context menus) or "middle". clicks: 1 (default) or 2 for a real double-click.
Fallback for when accessibility-based clicking can't reach a target — webview content (Tauri/Electron/CEF), custom-drawn UI, OpenGL/canvas, etc.
Delivery is system-global (kCGHIDEventTap on macOS) so the click actually reaches webviews — this was a regression in the first version, which used PID-targeted delivery that silently no-op'd on webview content while still returning ok. The cost is that the click affects whatever window is at (x, y), so make sure the target window is on top first.
window_id is advisory only — the call cannot verify delivery, just that the events were posted.
Inputs: x (required), y (required), button, clicks, +1 more.
dragA
Drag-and-drop with real pointer events: press at the source, move to
the target over duration seconds, release.
Give either screen coordinates (from_x/from_y, to_x/to_y, top-left origin in points) or element handles (from_handle/to_handle, from find_elements or get_tree); a handle is resolved to its on-screen center. You can mix: a handle on one end and coordinates on the other.
For drop targets that have no accessibility path — editors that only accept drops from another panel, list reordering, sliders. Delivery is system-global, so both ends must be visible and the window on top.
Inputs: to_x, to_y, button, from_x, +4 more.
find_elementsA
Find interactive elements by role and/or name. Returns element handles — pass the 'id' field to click/type_text/etc. Common roles: button, combo box, text, entry, check box, menu item. name is an exact match, not a substring ("Load Listfile…" with its ellipsis, "Export Folder" not "Export"); use get_tree to read names.
Inputs: window_id (required), name, role. Returns: result
get_treeA
Return the accessibility tree for a window as a compact JSON string. Use list_windows() first to get a window_id.
Empty, nameless layout wrappers are pruned automatically, so the tree stays compact even on content-heavy pages while every named or interactive node is preserved. Very large pages are additionally capped to a byte budget so the result always fits the client: when that happens the root object carries "truncated": true and "dropped_nodes" (a count). To see the cut content, pass max_depth to cap recursion depth, or use find_elements() to target specific controls.
max_depth counts the nameless wrappers that pruning hides, and web content (WebView2, WKWebView, Chromium) sits under several of them: a cap below ~12 can stop above a page's content. A node whose children the cap skipped carries "truncated_by_depth": true, so an empty "children" there means "not walked", not "empty". Right after a Tauri/WebView2 window appears its web pane may not exist yet; wait_for_element on a control you expect is the reliable way to know the page has loaded.
Returned as a compact JSON string (not a nested object) so the client's result-size cap counts real content, not indentation whitespace; parse it with a JSON loader.
Inputs: window_id (required), max_depth. Returns: result
get_valueC
Get the current text value or state of an element.
Inputs: handle_id (required).
learn_layoutA
Resolve semantic names to live element handle IDs for the current session.
Pass a map of {label: {role, name}} and get back {label: handle_id}. Store the result and use the handle IDs in run_sequence to skip repeated find_elements calls. Re-call after app restarts or window recreation.
Example: learn_layout("1234", { "bold_button": {"role": "push button", "name": "Bold"}, "editor": {"role": "document", "name": ""}, })
Inputs: window_id (required), descriptors (required).
list_windowsA
List all visible application windows on the desktop.
Fields per entry: id, name, pid, focused, minimized titles: top-level window titles where name is something else (process name on macOS, AT-SPI app name on Linux). bounds: [x, y, width, height] in global screen coords (top-left origin), or null when the platform can't report it. On a Linux Wayland session a native-Wayland app can't know its position, so bounds are null unless it runs under XWayland. Use this to translate screenshot pixels to click_at coordinates. accessible: false signals the platform a11y tree has nothing for this window — find_elements / get_tree will return empty. Skip those tools and use screenshot + click_at directly. Chromium-family windows (Electron/CEF) get an automatic wake poke first — their lazy a11y trees are enabled and re-probed before this is stamped false — so a false here means genuinely canvas-only: custom OpenGL/Vulkan surfaces, raw immediate-mode toolkits without an AccessKit adapter. (egui+accesskit surfaces as accessible=true.)
Inputs: no named parameters. Returns: result
press_keyA
Inject a key press into the focused element or a specific window. key: 'Down', 'Up', 'Left', 'Right', 'Return', 'Escape', 'Tab', 'Home', 'End', 'PageUp', 'PageDown', 'BackSpace', or any single character. Useful for navigating combo box dropdowns, dismissing dialogs, etc.
Combinations: 'Cmd+A', 'Ctrl+Shift+G', 'Alt+F4', 'Cmd+Shift+G'. 'Cmd' is the platform's shortcut key (Command on macOS, Ctrl on Windows/Linux), so 'Cmd+A' selects all everywhere; 'Super'/'Win' is the literal Windows key. Combinations need window_id and use real input, so that window is brought to the front first. On Linux they need X11 or an XWayland app.
Pass window_id. Without it the key goes to whatever app is in front, which is often your own terminal, and still returns ok.
Inputs: key (required), window_id.
run_sequenceA
Execute a list of actions server-side in a single call — no round-trips.
Each step is a dict with an "action" key plus action-specific fields: {"action": "click", "handle": "..."} {"action": "click_at", "x": 100.0, "y": 200.0, "window_id": null} {"action": "type", "handle": "...", "text": "..."} {"action": "key", "key": "Return"} {"action": "select", "handle": "...", "value": "..."} {"action": "get_value", "handle": "..."} {"action": "get_bounds", "handle": "..."} → value [x, y, w, h] or null {"action": "drag", "from_handle": "...", "to_handle": "..."} (or from_x/from_y, to_x/to_y; or dx/dy from the start: drags a splitter a known distance) {"action": "sleep", "seconds": 0.15} {"action": "wait_for_element", "window_id": "...", "role": "...", "name": "...", "state": "...", "timeout": 5.0} {"action": "wait_for_window", "pattern": "...", "timeout": 5.0}
Returns a list of per-step results. Stops at first failure unless stop_on_error is False.
Inputs: steps (required), stop_on_error.
screenshotA
Capture a screenshot as PNG. Last resort — prefer accessibility tools.
Pass window_id to crop to a specific window, or omit for full screen. Pass output_path (e.g. '/tmp/shot.png') to write the PNG to disk and get back {ok, path, width, height} — strongly preferred for any real-window capture, since base64-inline screenshots routinely overflow the response token budget. Without output_path, returns {ok, data, width, height} with base64-encoded PNG bytes.
width/height are pixel dimensions; on retina displays a window with 720×450 logical points captures at 1440×900 px. Divide by the window's point size to recover the scale factor for coordinate math.
Errors with {ok: False, error: ...} if window_id is given but the window cannot be located — does NOT silently fall back to a full-desktop capture.
Caveat on Linux/KWin: per-window capture crops a full-screen shot to the target window's rectangle, because the KDE compositor won't let unprivileged clients capture individual windows. Any other window on top of the target will appear in the crop. Raise or close overlapping windows before capturing if isolation matters. Windows and macOS capture per-window at the compositor level and are unaffected.
Inputs: window_id, output_path.
select_optionA
Select an option in a combo box or list by its visible text.
Tries the a11y item list, then SetValue on the control, then a keystroke fallback (real click to focus, type the item text, Return). Each strategy is verified by reading the value back; a strategy that leaves the control showing something else counts as a failure, so ok=false means it really did not change. The keystroke fallback needs the window on top.
Inputs: value (required), handle_id (required).
start_recordingA
Start recording the screen (or a specific window) as video. Returns a handle to pass to stop_recording. Crops to the window if window_id is provided.
Inputs: fps, window_id.
stop_recordingA
Stop recording and export.
output_path must end in .gif or .mp4. GIF is transcoded and downscaled to 900px wide at 12 fps, fine for a short clip in a README. MP4 keeps the full-resolution H.264 capture with no re-encode, so use it for anything longer than a minute or two (a 20-minute GIF is both huge and slow to build). Returns the path to the finished file.
Inputs: handle (required), output_path (required).
type_textA
Type text into a field. Uses native SetValue — foreground-independent. Falls back to key injection if the element doesn't support SetValue.
SetValue REPLACES the field's content. A window matched by title can be the user's own (single-instance apps such as Windows 11 Notepad reuse their open window), so type only into windows of a process you started.
paste=true also allows typing through the clipboard (Windows, macOS) when the other ways fail. The text is lent to the clipboard, marked out of clipboard history, and cleared afterwards if nothing replaced it; what the user had copied is lost, never restored. Password fields never paste.
Inputs: text (required), handle_id (required), paste.
wait_for_elementB
Wait until a matching element exists and optionally has the given state. Useful after clicks that trigger async UI changes.
Inputs: window_id (required), name, role, state, +1 more.
wait_for_windowA
Wait until a window matching title_pattern appears. Returns null on timeout. Useful after triggering an action that opens a dialog.
title_pattern is a case-insensitive substring (not a regex), matched against the listed name and every top-level window title, so a window title works on macOS (listed by process name) and Linux too.
Inputs: title_pattern (required), timeout.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 17 tools
Each tool targets a distinct action+resource: element-level click vs coordinate-level click_at, single-value get_value vs full-hierarchy get_tree, and wait tools for windows vs elements are all clearly separated. run_sequence overlaps with everything but is explicitly framed as a server-side batching optimization, not a competing primitive. Descriptions make the fallback ordering (a11y click -> click_at, SetValue -> key injection) explicit, so selection is unambiguous.
The overwhelming majority follow a clear verb_noun pattern: get_value, list_windows, press_key, get_tree, find_elements, type_text, select_option, wait_for_*, start_recording, stop_recording, learn_layout, run_sequence. Only a few break the pattern with bare verbs or nouns (click, drag, screenshot, click_at), which are still readable and idiomatic. Minor deviation keeps it from a perfect score.
17 tools is at the upper edge, but the surface genuinely spans accessibility-tree control, coordinate fallbacks, waiting, screen capture/recording, and batching, each earning its place. No obvious redundant tools; the higher count reflects a broad but coherent domain rather than bloat.
Covers the core UI automation lifecycle: inspect (list_windows, get_tree, find_elements, get_value), act (click, click_at, type_text, select_option, press_key, drag), wait, and capture/record, plus a batching tool. Minor gaps exist—no explicit scroll, hover/move, clipboard, or window-management (focus/close/minimize) tools—but these are largely workable via keys, press_key, and coordinates.
Maintenance
Related MCP Connectors
Connect ChatGPT and Claude to your Mac, Windows or Linux with secure MCP remote access.
An agent-first office suite Claude & ChatGPT read and write over one MCP URL.
Melaya is a remote MCP server. It gives an assistant hands on your own Android phone and browser: it reads the screen through the accessibility tree, then taps, types and navigates inside the apps and sites you allow-list, with no per-app API. It also builds, schedules and runs agent pipelines across 6k+ connected tools. OAuth 2.1, nothing to install.
Talk to your public-facing AI from any MCP client — Claude, ChatGPT, Cursor, Cline, Windsurf.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceAn open-source MCP server for macOS and Windows that provides native desktop control via Accessibility APIs, OCR, and Chrome CDP. It enables AI agents to interact with applications, manage browser sessions, and automate workflows with high-speed native UI actions.75 npm15AGPL 3.0
- AlicenseNot gradedqualityBmaintenanceAn MCP server that gives any AI assistant eyes and hands on your desktop — screenshots, clicking, typing, OCR, window management, accessibility-tree queries, workflow recording.5Apache 2.0
- AlicenseAqualityCmaintenanceCross-platform desktop automation MCP server that lets AI agents capture screenshots, run OCR with UI-element classification, control mouse/keyboard, and launch programs on Linux, macOS, and Windows.202MIT
- AlicenseAqualityAmaintenanceAn open-source MCP server that lets any coding agent operate your computer like a person does—reading the UI through accessibility trees, clicking and typing in the background, showing an agent pointer, zooming into regions, and driving your signed-in Chrome on macOS, Windows, and Linux.3735Apache 2.0