{"id":"57f40aec-da72-47ec-9d1d-bdc3aab4db31","slug":"winctl-mcp","kind":"mcp","name":"winctl-mcp","summary":"Local MCP server giving an agent full control of a Windows desktop: screen, mouse, keyboard, windows, applications, PowerShell, notifications.","owner":"Shult","repoUrl":"https://github.com/Shult/winctl-mcp","license":"MIT","licenseTier":"permissive","trust":"community","stars":1,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-07-29T06:47:41.000Z","addedAt":"2026-10-21T09:24:04.849Z","logoUrl":"https://avatars.githubusercontent.com/u/90676302?u=399d00b3ac189b3183315a31666554e16c5ebe61&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["cloud-infra","design-media"],"tags":["automation","claude","gui-automation","screenshot","windows"],"runtime":"python","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-21T10:20:25.015Z","sample":false,"url":"https://conduits.dev/tools/winctl-mcp","language":"Python","transports":["stdio"],"install":{"pypi":"winctl-mcp"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# winctl\n\n[![Tests](https://github.com/Shult/winctl-mcp/actions/workflows/tests.yml/badge.svg)](https://github.com/Shult/winctl-mcp/actions/workflows/tests.yml)\n\n**Give your agent the hands and eyes of a Windows user.**\n\n`winctl` is a local MCP server that exposes the whole desktop: see the screen,\nmove the mouse, type on the keyboard, manipulate windows, launch applications,\nrun PowerShell, and send you a summary when it is done. No driver to install, no\nservice to keep running, no account to create — one Python package, one\nregistration line, and the agent sees your desktop.\n\nEverything stays on the machine. Nothing leaves it, except the summaries you\nexplicitly ask for, to the channel you configure yourself.\n\n![Demo](https://raw.githubusercontent.com/Shult/winctl-mcp/main/docs/demo.gif)\n\n---\n\n## Requirements\n\n- **Windows 10 or 11.** The project is ctypes on top of the Win32 API: it runs\n  on no other system, and that is not a temporary limitation.\n- **Python 3.11 or later.**\n- **An open interactive session.** Input injection needs a desktop: no Windows\n  service, no container, no SSH without a graphical session.\n- An MCP client. The examples use [Claude Code](https://claude.com/claude-code),\n  but the server speaks standard MCP over stdio.\n\n## Installation\n\nWith [uv](https://docs.astral.sh/uv/):\n\n```bash\nuv tool install winctl-mcp\n```\n\nOr from source:\n\n```bash\ngit clone https://github.com/Shult/winctl-mcp\ncd winctl-mcp\nuv sync\n```\n\nCheck that the command answers:\n\n```bash\nwinctl-mcp --help\n```\n\n## Registering with Claude Code\n\n```bash\nclaude mcp add winctl --scope user -- winctl-mcp\n```\n\nFrom source, point at the virtual environment interpreter:\n\n```bash\nclaude mcp add winctl --scope user -- \"C:\\path\\to\\winctl-mcp\\.venv\\Scripts\\python.exe\" -m winctl.server\n```\n\n`--scope user` makes the server available in all your sessions, whatever the\nworking directory. Check it:\n\n```bash\nclaude mcp list\n```\n\nThe tools then appear under the `mcp__winctl__` prefix — for example\n`mcp__winctl__screenshot`. The `winctl` name is the one you gave to\n`claude mcp add`: if you register it under another name, the prefix follows.\n\nThen ask the agent: \"take a screenshot and tell me what you see\".\n\n---\n\n## The tools\n\n### See the screen\n\n| Tool | Purpose |\n| --- | --- |\n| `screenshot` | Capture one monitor, all monitors, or a specific region |\n| `screenshot_window` | Capture a given window, after bringing it to the foreground |\n| `screen_info` | List the monitors, their coordinates, the cursor position |\n\nTwo options change everything on `screenshot`:\n\n- **`grid: true`** overlays a grid annotated with real screen coordinates. It is\n  the most reliable way to aim a click to the pixel.\n- **`region_x/y/width/height`** captures a specific area. Far more legible than\n  a downscaled full screen when small text has to be deciphered.\n\n### Mouse and keyboard\n\n| Tool | Purpose |\n| --- | --- |\n| `mouse_move`, `mouse_click`, `mouse_drag`, `mouse_scroll` | Pointer control |\n| `type_text` | Type text, accented characters and emoji included |\n| `press_key`, `hotkey` | Single key or combination (`[\"ctrl\",\"c\"]`) |\n| `paste_text` | Insert through the clipboard, for long texts |\n| `clipboard` | Read / write the clipboard |\n\nKey names accept English as well as French: `enter`/`entree`, `escape`/`echap`,\n`delete`/`suppr`, `up`/`haut`, `down`/`bas`, `left`/`gauche`, `right`/`droite`,\n`shift`/`maj`, `space`/`espace`, `tab`, `f1`…`f24`, `win`.\n\n### Windows, applications, system\n\n| Tool | Purpose |\n| --- | --- |\n| `list_windows` | Open windows, with position, size and process |\n| `focus_window`, `window_action`, `move_window` | Foreground, minimize / maximize / close, move |\n| `launch_app` | Launch by common name, full path or URL |\n| `list_installed_apps` | Find the exact name of an installed application |\n| `run_powershell` | The escape hatch for everything else |\n| `wait` | Pause between two actions |\n\n`launch_app` accepts \"spotify\", \"chrome\", an `.exe` path or a URL. It chains\nPATH, the *App Paths* registry key, then the Start menu — which also covers\nMicrosoft Store applications, since those have no directly addressable\nexecutable.\n\n### Notifications\n\n| Tool | Purpose |\n| --- | --- |\n| `send_summary` | Send a summary, with a screenshot attached on request |\n| `notification_status` | Report which channels are configured, and which `.env` was read |\n| `telegram_find_chat_id` | Telegram setup helper |\n\n`send_summary` exists for long tasks: the agent works, you do something else,\nand the result lands on your phone. With no configuration, it falls back to a\nlocal Windows notification balloon.\n\n---\n\n## Configuring notifications (optional)\n\nCopy `.env.example` to `.env` and fill in what you need. No variable is\nmandatory.\n\n### Where winctl looks for the `.env`\n\nIt does not guess — that is deliberate. An MCP server is started by its client,\nwith the working directory of the session; and once `winctl` is installed as a\npackage, its code lives in a `site-packages` directory where nobody writes their\nconfiguration. Looking \"next to the code\" or \"under the current folder\" is only\nright by accident.\n\nThe location is therefore declared, in this order of priority:\n\n1. the path passed to `notify.load_env(path)` by the calling program;\n2. the **`WINCTL_ENV_FILE`** environment variable;\n3. failing that, `.env` in the current working directory.\n\nThe robust form is the second one, set when registering the server:\n\n```bash\nclaude mcp add winctl --scope user \\\n  --env WINCTL_ENV_FILE=C:\\Users\\you\\.config\\winctl\\.env -- winctl-mcp\n```\n\nWhen in doubt, `notification_status` reports the file actually loaded, or the\nlist of locations tried.\n\nThe `.env` is reread on every action: completing the file does not require\nrestarting the server. A variable already present in the parent process\nenvironment (through `--env`, for instance) always wins over the file.\n\n### Telegram — recommended\n\nIt is the only channel that can be set up in two minutes: no OAuth, no domain to\nverify, no prior review.\n\n1. On Telegram, write to **@BotFather**, send `/newbot`, follow the steps.\n2. Paste the resulting token into `TELEGRAM_BOT_TOKEN` in the `.env` file.\n3. Send any message to your new bot.\n4. Ask the agent: \"call telegram_find_chat_id\". Copy the returned identifier\n   into `TELEGRAM_CHAT_ID`.\n\n### Email\n\nFill in `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM` and\n`SMTP_TO`. With Gmail, use an\n[app password](https://myaccount.google.com/apppasswords), not the account\npassword.\n\n---\n\n## Safety, and what this project cannot do\n\nThis section is long on purpose. A tool that hands control of a desktop to a\nlanguage model deserves precision about its edges.\n\n**What you are granting.** An agent equipped with `winctl` can do to your\nsession anything you could do there: read your open windows, type into your\nmessaging app, delete files through `run_powershell`. There is no sandbox. That\nis the point of the tool, and it is also its risk: do not register it on a\nmachine whose contents are not yours.\n\n**Observation mode.** Set `WINCTL_READONLY=1` in the `.env` to allow only\ncaptures and reads. Keyboard, mouse, window moves, application launches and\nPowerShell are then refused with an explicit message. It is the right setting\nfor a first hands-on, or to let an agent diagnose without acting.\n\n**Windows running as administrator.** Windows forbids a non-elevated process\nfrom sending input to an elevated window: that is the *UIPI* mechanism, and\nthere is no clean way around it. If an application started as administrator\nignores keystrokes, this is why. The MCP client itself must be started as\nadministrator to drive it — with everything that implies.\n\n**Windows only.** The `winapi` module is a direct ctypes binding on `user32`:\n`SendInput`, `EnumWindows`, `SetForegroundWindow`. Nothing is portable, and\nnothing is meant to become so.\n\n**A graphical session is required.** Without an interactive desktop, `SendInput`\nhas nowhere to write. A server with no open session, a locked session or a\nsecure desktop (the UAC prompt, Ctrl+Alt+Del) are out of reach: those are\nseparate desktops, protected by the system.\n\n**Targeting the right window.** The typing tools accept a `window` parameter.\nThe target window is brought to the foreground and **the action is cancelled if\nfocus fails**, rather than typing into the active application by mistake. Use it\nsystematically for any keystroke that matters.\n\n**Multi-monitor.** Coordinates are in real pixels of the virtual desktop and may\nbe negative: a monitor placed to the left of the primary one has x coordinates\nbelow zero. Call `screen_info` when in doubt rather than assuming.\n\n**Games and exclusive fullscreen applications.** They often capture input at a\nlevel `SendInput` does not reach, and their rendering can escape screen capture.\nBorderless windowed mode generally works.\n\n**Long texts.** Prefer `paste_text` over `type_text` beyond a few hundred\ncharacters: it is instantaneous and immune to applications that drop keystrokes.\n\n---\n\n## Notable implementation details\n\nThese choices deserve an explanation, because they fix real failures observed in\nuse. If you hesitate to trust this code, this is the section to read.\n\n**Character-by-character Unicode typing.** The keyboard goes through `SendInput`\nwith `KEYEVENTF_UNICODE`, which makes typing independent from the keyboard\nlayout — \"é\", \"ç\" or \"€\" come out identical on AZERTY and QWERTY. But modern\ncontrols (WinUI / Text Services Framework, including the Windows 11 Notepad)\nread injected events asynchronously: when they fall behind, every pending\nevent takes the value of the last one. \"winctl\" comes out \"nnnctl\", and an emoji\n— a surrogate pair, two UTF-16 units — lands as two broken halves, even when\nboth halves share a single call. One `SendInput` call per UTF-16 unit, spaced\nby 15 ms, delivers the text intact: measured on a cold-started Notepad under\nload, 3 ms failed every round, 15 ms never did.\n\n**Focus verified, never assumed.** Windows only grants `SetForegroundWindow` to\nthe process that emitted the last user input. The server makes itself eligible\nby synthesising an ALT keystroke, attaches its input queue to that of the active\nwindow, then **checks the result** and retries. Typing into the wrong window is\nthe worst possible failure for this tool: a clean error beats a message sent to\nthe wrong conversation.\n\n**DPI awareness enabled at startup.** Without\n`SetProcessDpiAwarenessContext`, Windows lies about coordinates and screen sizes\nas soon as one monitor is not at 100% scaling: the agent would aim right and\nclick beside.\n\n**Grid in real coordinates, not image pixels.** Captures are resized to fit in\nthe model's context. Annotating the grid with image coordinates would force a\nconversion back, hence a rounding error per click; the labels therefore carry\nthe clickable value directly. The summary attached to every capture restates the\nconversion formula, for the cases where the model reasons on the image anyway.\n\n**Interpolated mouse movement for drag and drop.** An instant jump from one\npoint to another is ignored by most applications, which expect a sequence of\nmovements to arm the drag. `mouse_drag` interpolates the path.\n\n**The `.env` is reread on every action, never cached.** Completing the\nconfiguration therefore does not require restarting the server — which matters\nwhen it runs under a client one is reluctant to restart. Empty values are\nignored, and a line removed from the file removes the variable.\n\n---\n\n## Environment variable reference\n\nAll of them go in the `.env` (see `.env.example`). None is mandatory.\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `WINCTL_ENV_FILE` | — | Path of the `.env` to load. Read from the environment, not from the `.env` |\n| `WINCTL_READONLY` | — | `1` blocks keyboard, mouse, windows, applications and PowerShell |\n| `TELEGRAM_BOT_TOKEN` | — | @BotFather token, for `send_summary` |\n| `TELEGRAM_CHAT_ID` | — | Destination conversation |\n| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`, `SMTP_TO` | port `587` | Email channel of `send_summary` |\n\n---\n\n## Tests\n\n```bash\nuv sync --group dev\nuv run pytest\n```\n\nThe suite covers the pure functions (`.env` resolution, key normalisation,\nnotification dispatch), the geometry of captures on the real screen, and the MCP\nserver contract — its name, its tool list, and the tightness of read-only mode.\n`tests/test_readme.py` holds this README to the code: the tool tables, the tool\ncount and the typing delay are checked against the sources.\n\nTwo `autouse` guardrails are set in `tests/conftest.py` and checked by\n`tests/test_guardrails.py`: `httpx` and `smtplib` raise as soon as they are\ntouched. The suite runs on the machine it drives; it must not be able to send a\nreal message.\n\nThe same suite runs on GitHub Actions (`windows-latest`, Python 3.11 and\n3.13) on every push to `main` and every pull request.\n\n**What is not covered, and why.** Input injection — `type_text`, `mouse_click`,\n`hotkey` — is not tested automatically: a test checking that `Ctrl+W` works\ncloses a window along the way. Those functions are validated by hand. Likewise,\nthe capture tests assert nothing about the *content* of the image, only about\nits structure: nothing may depend on what is displayed at the time of the test.\n\n---\n\n## Layout\n\n```\nsrc/winctl/\n  server.py    definition of the 23 MCP tools\n  winapi.py    ctypes layer: SendInput, windows, clipboard\n  capture.py   screen captures, resizing, coordinate grid\n  apps.py      application resolution and launching\n  notify.py    Telegram, email, Windows notification, .env loading\n```\n\n`capture` and `notify` are not third-party libraries: they are the internals of\nthe server. `capture` backs the capture tools, `notify` backs `send_summary`.\n\n---\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n"}