Skip to main content
Glama

winmcp

A window-addressed desktop control MCP server for Windows that never takes the foreground.

The core promise: it addresses one window by handle, acts through Win32 messages and UIA patterns, and never moves your cursor or steals your focus. When an action genuinely needs the foreground, it refuses and returns a needs_permission result instead of doing it — the caller must ask you first.

Why not patch window-mcp

The model of the usual desktop MCP is "snapshot the whole desktop (every window's UIA tree), hand back coordinates, move the real cursor, click." That has three structural costs: a full-desktop traversal takes 11–15 s, a real cursor cannot coexist with a human, and coordinates only hold for the snapshot that produced them.

This tool changes the model: address a single window by hwnd and act with window messages and PrintWindow.

Related MCP server: desktop-automation

Measurements (on a normal Windows desktop)

Operation

Time

Takes foreground

windows() list

~1 ms

no

PrintWindow background capture

16–31 ms

no

UIA tree read

5 ms – 14 s

no

Win32 BM_CLICK window message

0.0 ms

no

Win32 WM_SETTEXT write

0.3 ms

no

UIA InvokePattern

504–518 ms

yes

UIA ValuePattern.SetValue

514 ms

yes

The conclusion that shaped the design: UIA pattern invocation is what steals the foreground, and it is three orders of magnitude slower than a window message. So window messages are the primary route, and UIA is only used after the user agrees.

Tools

  • windows() — visible top-level windows: hwnd, title, class, pid, rect, foreground flag

  • see(hwnd, with_tree=false) — PrintWindow capture plus an optional UIA tree with a hard timeout

  • click(hwnd, target) — window message first, needs_permission otherwise

  • type_text(hwnd, target, text) — WM_SETTEXT first, verified by read-back

  • key(hwnd, keys) — keystrokes; always needs the foreground

  • scroll(hwnd, target, direction) — background scrolling is unreliable, expect a refusal

  • open_app(command) — starts a program without letting it pop to the front

  • reveal(hwnd) — shows a hidden window; this one does take the foreground

The permission gate

When an action cannot be done without the foreground:

{
  "ok": false,
  "needs_permission": true,
  "reason": "no native control matching 'X' that accepts a window message",
  "detail": "'Window title' can only be reached through UIA or the real cursor...",
  "hint": "Show this to the user. Retry with allow_foreground=true only after they agree."
}

Install

git clone <this repo> $env:LOCALAPPDATA\winmcp
cd $env:LOCALAPPDATA\winmcp
uv venv .venv --python 3.14
uv pip install --python .venv\Scripts\python.exe "windows-mcp==0.8.8"
uv pip install --python .venv\Scripts\python.exe -e .
pwsh -File .\install.ps1     # registers the logon task, starts the server

The server listens on http://127.0.0.1:8765/mcp.

Register it in a client, e.g. Codex ~/.codex/config.toml:

[mcp_servers.winmcp]
url = "http://127.0.0.1:8765/mcp"
required = false

Do not install under a directory with capability SIDs

PrintWindow returns ERROR_ACCESS_DENIED for every window when the interpreter lives under a directory whose ACL carries S-1-4-* application package capability SIDs. Some user profile folders inherit them. If capture works from one venv and fails from another with identical code and Python build, check the ACL before anything else:

(Get-Acl $path).Access | Where-Object { $_.IdentityReference -like 'S-1-4-*' }

Installing under %LOCALAPPDATA% avoids the issue.

Self-check

cd $env:LOCALAPPDATA\winmcp
.venv\Scripts\python.exe -m winmcp.check

36 assertions. It creates its own window, so it never touches yours.

Known limits

  1. Chromium / Electron / Flutter / games have no Win32 child controls. click always goes through the permission gate there.

  2. Electron apps hide their UI from accessibility unless launched with it on. See below — this is the difference between a readable app and a black box.

  3. Background UIA reads are slow for browsers (10+ s). The timeout stops it and returns timed_out: true rather than hanging.

  4. PrintWindow returns blank frames for GPU overlays, protected content and minimised windows. Reported as image_unavailable, never as a blank picture.

  5. UWP shell windows read as zero elements. An ApplicationFrameWindow really does contain only empty title-bar and input-sink children; the content lives in a separate CoreWindow.

  6. Win32 multiline edits expose no ScrollPattern. Only the mouse wheel moves them.

  7. Chromium inputs return None from ValuePattern.Value even when the text is there, so read-back verification is impossible; pixel comparison is the only check.

Electron apps are black boxes until accessibility is switched on

Chromium builds its accessibility tree lazily, and an outside UIA client asking for it is not enough to switch it on. Measured on QQ (Chromium 9.9.22):

Launch

Elements read

Named

plain

9

0

--force-renderer-accessibility

128

126

Those 9 are empty PaneControl shells wrapping the D3D surface — no names, no addressable controls, nothing to click by name.

And Chromium throws the tree away again as soon as another window comes forward. Same window, same session, only the foreground changed:

Foreground

Elements

QQ

132

your editor

0

QQ again

132

That is why an agent can read a window one moment and find nothing the next. Two flags stop it, and either one alone is enough:

  • --disable-backgrounding-occluded-windows

  • --disable-features=CalculateNativeWinOcclusion

open_app(..., accessibility=True) passes all three flags. With them a backgrounded QQ reads 132 elements on every poll while your editor keeps the foreground.

Clicks that never steal the foreground

UIA InvokePattern on Chromium goes through IA2, which focuses the widget and takes the foreground. LegacyIAccessible.DoDefaultAction does the same work through MSAA and leaves focus alone. Measured on the same backgrounded window:

Route

Effect landed

Cursor moved

Foreground moved

LegacyIAccessible.DoDefaultAction

yes

no

no

InvokePattern

no

no

yes

posted WM_LBUTTONDOWN to render host

no

no

no

So click() tries the legacy route first, before touching the window's activation state at all. Only if that finds no target does it bring the window forward and fall back to the modern patterns.

False successes this tool had to fix

Three separate times an API reported success while nothing happened. They are now guarded, and the lesson is in the code:

  • SetForegroundWindow on a hidden window returns success and GetForegroundWindow points at it, but the screen does not change. Guarded by also requiring IsWindowVisible and not IsIconic.

  • ValuePattern.SetValue on a Chromium input returns silently and stores nothing. Guarded by reading the value back and falling back to click-then-type.

  • PostMessage(BM_CLICK) to a render host returns 1 and does nothing. Guarded by only messaging real button classes.

Real-world testing

128 assertions across seven suites, against real applications — Notepad, Paint, VS Code, Explorer, Calculator, Notepad++, and a live browser session.

Suite

Covers

Result

T1

windows() completeness, accuracy, cost

9/9

T2

see() across Win32 / WinUI / Electron / UWP / desktop

5/5

T3

click() every route

10/10

T4

type_text() every route plus encoding edges

9/9

T5

key() notation and real keystrokes

10/10

T6

scroll() capability and honest degradation

1/1

T7

open_app() / reveal()

30/30

T8

hostile input, concurrency, leaks

31/31

T9

MCP protocol, schemas, error shapes

23/23

Verified end to end: drove a real browser to a search page, read the results from the UIA tree, and clicked through to a repository page; filled and submitted an HTML form and confirmed every field arrived by reading the server's JSON response.

Design notes worth keeping

Key notation is translated, not passed through. SendKeys wants {Ctrl}s; a caller writing ^s would get those characters typed literally into the document. _normalise_keys accepts ^s, Ctrl+s, ^{END} and emits the right form. A chord is only a chord when every part before the last is a modifier name — otherwise 1+1 becomes {1}{1} and the plus disappears.

Pattern getters are not universal. GroupControl has no GetInvokePattern, WindowControl has no GetScrollPattern. Every pattern access goes through one guarded helper; a bare attribute access crashes on real UIs.

Silent launch is a fight, not a race. A launched app calls SetForegroundWindow about 200 ms in, sooner than any hook can hide its window. So the tool hides new windows every pass and takes the foreground back every pass until the situation is stable. foreground_contests reports how often the app tried.

Verify screen changes with a screenshot. Every failure mode above reported success. No API return value is trusted for anything visible.

Credential dialogs stall git commands. On Windows, git push / git remote add can trigger Git Credential Manager's "Select an account" window (an Avalonia top-level window, class Avalonia-*). It blocks the command until an account is chosen. Its title has no "git" keyword, so a keyword-based window scan misses it and the agent waits forever with no output. The rule is shipped to every connected client in the server's MCP instructions: if a git command stalls, call windows(), find the new window, then see(hwnd, with_tree=True) to click the account ListItem and Continue. Match by title/class, not by pid — the dialog belongs to a different process.

License

MIT

Related MCP Connectors

Related MCP Servers

  • A
    license
    C
    quality
    C
    maintenance
    Enables agent-native desktop control by driving mouse, keyboard, and interface elements through accessibility-first semantic operations, with fallback to screen coordinates when accessibility cannot reach a target.
    30
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables secure, deny-by-default automation of Windows windows with per-action permissions, allowing observation, clicking, typing, and dragging on approved applications while verifying target identity and focus at runtime.
    Apache 2.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI clients to inspect and control allowlisted Windows applications using screenshots, mouse actions, Unicode keyboard input, UI Automation, and an Esc/F12 emergency stop.
    1
    Apache 2.0
  • A
    license
    A
    quality
    F
    maintenance
    Lets Claude Desktop and Claude Code inspect and drive Windows desktop applications in the background, including clicking, typing, launching, and verifying results, while the user keeps their mouse, keyboard, and foreground window.
    7
    176 PyPI
    4
    MIT