Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,10 @@ MCP Client can access the following tools to interact with Windows:
drag, set `from_loc=[x, y]` with `drag=True` to press at an explicit start point and
release at `loc` in one tool call. Optional `duration` adds bounded intermediate
movement.
- `Pointer`: Build a stateful gesture across calls with `down`, one or more `move`
actions, and `up`. Use `cancel` for recovery; held buttons are automatically released
after 30 seconds by default (maximum 120 seconds). Prefer `Move(drag=True)` for a
simple atomic drag.
- `Shortcut`: Press keyboard shortcuts (`Ctrl+c`, `Alt+Tab`, etc).
- `Wait`: Pause for a defined duration.
- `WaitFor`: Wait until text, an active window, an element, or a focused element appears by polling UI state inside one tool call.
Expand Down
3 changes: 2 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ These tools can make permanent changes to your system:
|------|------|-------------|
| **Shell** | Critical | Can execute arbitrary PowerShell commands, including system modifications, file deletions, and network operations |
| **Click** | High | Can trigger destructive UI actions (delete confirmations, system dialogs) |
| **Pointer** | High | Can hold and move mouse buttons across calls; use `up` or `cancel` to finish gestures |
| **Type** | High | Can overwrite text, potentially destroying data when `clear=True` |
| **Drag** | High | Can move/reorganize files, potentially overwriting existing files |
| **Shortcut** | High | Can execute destructive keyboard shortcuts (Ctrl+D delete, Alt+F4 close) |
Expand Down Expand Up @@ -318,4 +319,4 @@ Users are solely responsible for:

## License

This security policy is part of the Windows-MCP project, licensed under the MIT License. See [LICENSE](LICENSE.md) for details.
This security policy is part of the Windows-MCP project, licensed under the MIT License. See [LICENSE](LICENSE.md) for details.
4 changes: 4 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@
"name": "Move",
"description": "Moves mouse cursor to coordinates [x, y] or passing a UI element's label/id. Set drag=True to perform a drag-and-drop operation from the current mouse position to the target coordinates. Default (drag=False) is a simple cursor move (hover). Provide either loc or label."
},
{
"name": "Pointer",
"description": "Controls one stateful mouse-button gesture across calls with down, move, up, and cancel actions. Held buttons are automatically released after 30 seconds by default (maximum 120 seconds). Prefer Move with drag=True for a simple atomic drag."
},
{
"name": "Shortcut",
"description": "Executes keyboard shortcuts using key combinations separated by +. Examples: \"ctrl+c\" (copy), \"ctrl+v\" (paste), \"alt+tab\" (switch apps), \"win+r\" (Run dialog), \"win\" (Start menu), \"ctrl+shift+esc\" (Task Manager). Use for quick actions and system commands."
Expand Down
5 changes: 5 additions & 0 deletions src/windows_mcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@ async def lifespan(app: FastMCP):
yield
finally:
logger.debug("Shutting down: stopping watchdog and analytics")
if desktop:
try:
desktop.close()
except Exception:
logger.exception("Failed to release desktop input during shutdown")
if watchdog:
watchdog.stop()
if analytics:
Expand Down
255 changes: 255 additions & 0 deletions src/windows_mcp/desktop/pointer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
"""Stateful mouse pointer control with bounded automatic release."""

from collections.abc import Callable
import logging
import math
from threading import RLock, Timer
from typing import Literal

import windows_mcp.uia as uia


MouseButton = Literal["left", "right", "middle"]
DEFAULT_POINTER_TIMEOUT = 30.0
MAX_POINTER_TIMEOUT = 120.0
MAX_POINTER_MOVE_DURATION = 10.0

logger = logging.getLogger(__name__)


def normalize_pointer_button(value: object, *, allow_none: bool = False) -> MouseButton | None:
"""Validate a mouse button name."""
if value is None and allow_none:
return None
if value not in {"left", "right", "middle"}:
raise ValueError("button must be one of: left, right, middle")
return value


def normalize_pointer_point(value: object, name: str = "loc") -> tuple[int, int]:
"""Validate a screen point without accepting booleans or fractional coordinates."""
if not isinstance(value, (list, tuple)) or len(value) != 2:
raise ValueError(f"{name} must be a list or tuple of exactly 2 integers [x, y]")
x, y = value
if any(isinstance(item, bool) or not isinstance(item, int) for item in (x, y)):
raise ValueError(f"{name} must contain exactly 2 integers")
return x, y


def normalize_pointer_duration(value: object | None) -> float | None:
"""Validate an optional bounded pointer movement duration."""
if value is None:
return None
if isinstance(value, bool):
raise ValueError("duration must be a finite number of seconds")
try:
duration = float(value)
except (TypeError, ValueError) as exc:
raise ValueError("duration must be a finite number of seconds") from exc
if not math.isfinite(duration):
raise ValueError("duration must be a finite number of seconds")
if duration < 0 or duration > MAX_POINTER_MOVE_DURATION:
raise ValueError(f"duration must be between 0 and {MAX_POINTER_MOVE_DURATION:g} seconds")
return duration


def normalize_pointer_timeout(value: object | None) -> float:
"""Validate the automatic mouse-button release timeout."""
if value is None:
return DEFAULT_POINTER_TIMEOUT
if isinstance(value, bool):
raise ValueError("timeout must be a finite number of seconds")
try:
timeout = float(value)
except (TypeError, ValueError) as exc:
raise ValueError("timeout must be a finite number of seconds") from exc
if not math.isfinite(timeout):
raise ValueError("timeout must be a finite number of seconds")
if timeout <= 0 or timeout > MAX_POINTER_TIMEOUT:
raise ValueError(
f"timeout must be greater than 0 and at most {MAX_POINTER_TIMEOUT:g} seconds"
)
return timeout


class PointerController:
"""Serialize stateful mouse input and prevent indefinitely held buttons."""

def __init__(
self,
timer_factory: Callable[[float, Callable[[], None]], Timer] = Timer,
) -> None:
self._lock = RLock()
self._button: MouseButton | None = None
self._timer: Timer | None = None
self._generation = 0
self._timer_factory = timer_factory

@property
def held_button(self) -> MouseButton | None:
"""Return the button currently held by this controller."""
with self._lock:
return self._button

@staticmethod
def _press(button: MouseButton, x: int, y: int) -> None:
if button == "left":
uia.PressMouse(x, y, waitTime=0.05)
elif button == "right":
uia.RightPressMouse(x, y, waitTime=0.05)
else:
uia.MiddlePressMouse(x, y, waitTime=0.05)

@staticmethod
def _release(button: MouseButton) -> None:
if button == "left":
uia.ReleaseMouse(waitTime=0.05)
elif button == "right":
uia.RightReleaseMouse(waitTime=0.05)
else:
uia.MiddleReleaseMouse(waitTime=0.05)

def _clear_locked(self, *, cancel_timer: bool) -> None:
timer = self._timer
self._timer = None
self._button = None
self._generation += 1
if cancel_timer and timer is not None:
timer.cancel()

def _release_all_locked(self) -> None:
first_error: Exception | None = None
for button in ("left", "right", "middle"):
try:
self._release(button)
except Exception as exc:
if first_error is None:
first_error = exc
self._clear_locked(cancel_timer=True)
if first_error is not None:
raise RuntimeError("Failed to release one or more mouse buttons") from first_error

def _timeout_release(self, generation: int) -> None:
with self._lock:
if self._button is None or self._generation != generation:
return
try:
self._release_all_locked()
except Exception:
logger.exception("Automatic pointer release failed")

def down(
self,
loc: tuple[int, int] | list[int],
button: MouseButton = "left",
timeout: float | int | str | None = None,
) -> dict[str, object]:
"""Press one mouse button and schedule a bounded automatic release."""
x, y = normalize_pointer_point(loc)
normalized_button = normalize_pointer_button(button)
normalized_timeout = normalize_pointer_timeout(timeout)

with self._lock:
if self._button is not None:
raise RuntimeError(
f"Cannot press {normalized_button}; {self._button} mouse button is already held"
)
try:
self._press(normalized_button, x, y)
except BaseException as press_error:
try:
self._release(normalized_button)
except BaseException as release_error:
raise release_error from press_error
raise

self._button = normalized_button
self._generation += 1
generation = self._generation
try:
timer = self._timer_factory(
normalized_timeout,
lambda: self._timeout_release(generation),
)
timer.daemon = True
self._timer = timer
timer.start()
except BaseException as timer_error:
try:
self._release(normalized_button)
except BaseException as release_error:
raise release_error from timer_error
finally:
self._clear_locked(cancel_timer=True)
raise

return {
"action": "down",
"button": normalized_button,
"loc": [x, y],
"timeout": normalized_timeout,
}

def move(
self,
loc: tuple[int, int] | list[int],
duration: float | int | str | None = None,
) -> dict[str, object]:
"""Move the pointer while the tracked mouse button remains held."""
x, y = normalize_pointer_point(loc)
normalized_duration = normalize_pointer_duration(duration)

with self._lock:
if self._button is None:
raise RuntimeError("Cannot move pointer because no mouse button is held")
button = self._button
try:
if normalized_duration is None:
uia.MoveTo(x, y, moveSpeed=10, waitTime=0.05)
else:
uia.MoveToDuration(x, y, normalized_duration, waitTime=0.05)
except BaseException as move_error:
try:
self._release(button)
except BaseException as release_error:
raise release_error from move_error
else:
self._clear_locked(cancel_timer=True)
raise

return {
"action": "move",
"button": button,
"loc": [x, y],
"duration": normalized_duration,
}

def up(self, button: MouseButton | None = None) -> dict[str, object]:
"""Release the tracked mouse button, optionally asserting its identity."""
normalized_button = normalize_pointer_button(button, allow_none=True)
with self._lock:
if self._button is None:
raise RuntimeError("Cannot release pointer because no mouse button is held")
if normalized_button is not None and normalized_button != self._button:
raise RuntimeError(
f"Cannot release {normalized_button}; {self._button} mouse button is held"
)
held_button = self._button
self._release(held_button)
self._clear_locked(cancel_timer=True)
return {"action": "up", "button": held_button}

def cancel(self) -> dict[str, object]:
"""Best-effort release every supported mouse button and clear tracked state."""
with self._lock:
held_button = self._button
self._release_all_locked()
return {"action": "cancel", "button": held_button}

def close(self) -> None:
"""Release tracked input during a normal server shutdown."""
with self._lock:
if self._button is None:
self._clear_locked(cancel_timer=True)
return
self._release_all_locked()
31 changes: 31 additions & 0 deletions src/windows_mcp/desktop/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from windows_mcp.tree.service import Tree
from windows_mcp.desktop import screenshot as screenshot_capture
from windows_mcp.desktop import flash_overlay
from windows_mcp.desktop.pointer import MouseButton, PointerController
from windows_mcp.infrastructure import validate_url
from urllib.parse import urljoin
from locale import getpreferredencoding
Expand Down Expand Up @@ -81,6 +82,11 @@ def __init__(self):
self.encoding = getpreferredencoding()
self.tree = Tree(self)
self.desktop_state = None
self._pointer = PointerController()

def close(self) -> None:
"""Release stateful input owned by this desktop service."""
self._pointer.close()

def get_state(
self,
Expand Down Expand Up @@ -822,6 +828,31 @@ def move(self, loc: tuple[int, int]):
x, y = loc
uia.MoveTo(x, y, moveSpeed=10)

def pointer_down(
self,
loc: tuple[int, int] | list[int],
button: MouseButton = "left",
timeout: float | int | str | None = None,
) -> dict[str, object]:
"""Press a mouse button for a bounded stateful gesture."""
return self._pointer.down(loc, button, timeout)

def pointer_move(
self,
loc: tuple[int, int] | list[int],
duration: float | int | str | None = None,
) -> dict[str, object]:
"""Move the pointer while its tracked button remains held."""
return self._pointer.move(loc, duration)

def pointer_up(self, button: MouseButton | None = None) -> dict[str, object]:
"""Release the tracked mouse button."""
return self._pointer.up(button)

def pointer_cancel(self) -> dict[str, object]:
"""Release all mouse buttons and clear tracked pointer state."""
return self._pointer.cancel()

def shortcut(self, shortcut: str):
keys = shortcut.split("+")
sendkeys_str = ""
Expand Down
2 changes: 2 additions & 0 deletions src/windows_mcp/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
input,
multi,
notification,
pointer,
process,
registry,
scrape,
Expand All @@ -22,6 +23,7 @@
filesystem,
snapshot,
input,
pointer,
scrape,
multi,
clipboard,
Expand Down
Loading