diff --git a/README.md b/README.md index 1e27faed..1e3f5a82 100755 --- a/README.md +++ b/README.md @@ -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. diff --git a/SECURITY.md b/SECURITY.md index 1a7d9d28..50c0aa51 100755 --- a/SECURITY.md +++ b/SECURITY.md @@ -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) | @@ -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. \ No newline at end of file +This security policy is part of the Windows-MCP project, licensed under the MIT License. See [LICENSE](LICENSE.md) for details. diff --git a/manifest.json b/manifest.json index 9bb66d05..f73b119f 100755 --- a/manifest.json +++ b/manifest.json @@ -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." diff --git a/src/windows_mcp/__main__.py b/src/windows_mcp/__main__.py index c57e8690..953a288f 100755 --- a/src/windows_mcp/__main__.py +++ b/src/windows_mcp/__main__.py @@ -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: diff --git a/src/windows_mcp/desktop/pointer.py b/src/windows_mcp/desktop/pointer.py new file mode 100644 index 00000000..ce3a3c51 --- /dev/null +++ b/src/windows_mcp/desktop/pointer.py @@ -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() diff --git a/src/windows_mcp/desktop/service.py b/src/windows_mcp/desktop/service.py index 320e0a1e..0e91faab 100755 --- a/src/windows_mcp/desktop/service.py +++ b/src/windows_mcp/desktop/service.py @@ -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 @@ -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, @@ -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 = "" diff --git a/src/windows_mcp/tools/__init__.py b/src/windows_mcp/tools/__init__.py index b4b5c95c..99f70252 100644 --- a/src/windows_mcp/tools/__init__.py +++ b/src/windows_mcp/tools/__init__.py @@ -8,6 +8,7 @@ input, multi, notification, + pointer, process, registry, scrape, @@ -22,6 +23,7 @@ filesystem, snapshot, input, + pointer, scrape, multi, clipboard, diff --git a/src/windows_mcp/tools/pointer.py b/src/windows_mcp/tools/pointer.py new file mode 100644 index 00000000..9e0e9013 --- /dev/null +++ b/src/windows_mcp/tools/pointer.py @@ -0,0 +1,117 @@ +"""Pointer tool — stateful mouse down, move, up, and cancel actions.""" + +import json +from typing import Any, Literal, cast + +from fastmcp import Context +from mcp.types import ToolAnnotations + +from windows_mcp.desktop.pointer import ( + MouseButton, + normalize_pointer_button, + normalize_pointer_duration, + normalize_pointer_point, + normalize_pointer_timeout, +) +from windows_mcp.infrastructure import with_analytics + + +PointerAction = Literal["down", "move", "up", "cancel"] + + +def _normalize_action(value: object) -> PointerAction: + if not isinstance(value, str): + raise ValueError("action must be one of: down, move, up, cancel") + normalized = value.strip().lower() + if normalized not in {"down", "move", "up", "cancel"}: + raise ValueError("action must be one of: down, move, up, cancel") + return cast(PointerAction, normalized) + + +def _as_point(value: list[int] | str | None) -> tuple[int, int]: + if value is None: + raise ValueError("loc is required for Pointer down and move actions") + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError as exc: + raise ValueError("loc must be a JSON list of exactly 2 integers [x, y]") from exc + return normalize_pointer_point(value) + + +def register(mcp: Any, *, get_desktop, get_analytics) -> None: + @mcp.tool( + name="Pointer", + description=( + "Controls one stateful mouse-button gesture across calls. Use action='down' with loc, " + "an optional left/right/middle button, and an automatic-release timeout that defaults " + "to 30 seconds and is limited to 120 seconds; then action='move' one or more times with " + "loc and optional duration; finish with action='up'. Use action='cancel' to release all " + "mouse buttons and clear held state. Prefer Move with drag=True for a simple atomic drag " + "in one call." + ), + annotations=ToolAnnotations( + title="Pointer", + readOnlyHint=False, + destructiveHint=True, + idempotentHint=False, + openWorldHint=False, + ), + ) + @with_analytics(get_analytics(), "Pointer-Tool") + def pointer_tool( + action: Literal["down", "move", "up", "cancel"], + loc: list[int] | str | None = None, + button: Literal["left", "right", "middle"] | None = None, + duration: float | int | str | None = None, + timeout: float | int | str | None = None, + ctx: Context = None, + ) -> str: + normalized_action = _normalize_action(action) + + if normalized_action == "down": + if duration is not None: + raise ValueError("duration is only supported for action='move'") + point = _as_point(loc) + requested_button = "left" if button is None else button + normalized_button = cast( + MouseButton, + normalize_pointer_button(requested_button), + ) + normalized_timeout = normalize_pointer_timeout(timeout) + result = get_desktop().pointer_down(point, normalized_button, normalized_timeout) + x, y = result["loc"] + return ( + f"Pressed {result['button']} mouse button at ({x},{y}); " + f"automatic release timeout is {result['timeout']:g} seconds." + ) + + if normalized_action == "move": + if button is not None or timeout is not None: + raise ValueError("button and timeout are only supported for action='down'") + point = _as_point(loc) + normalized_duration = normalize_pointer_duration(duration) + result = get_desktop().pointer_move(point, normalized_duration) + x, y = result["loc"] + if result["duration"] is None: + return f"Moved held {result['button']} mouse button to ({x},{y})." + return ( + f"Moved held {result['button']} mouse button to ({x},{y}) " + f"over {result['duration']:.3f} seconds." + ) + + if normalized_action == "up": + if loc is not None or duration is not None or timeout is not None: + raise ValueError("loc, duration, and timeout are not supported for action='up'") + normalized_button = normalize_pointer_button(button, allow_none=True) + result = get_desktop().pointer_up(normalized_button) + return f"Released {result['button']} mouse button." + + if any(value is not None for value in (loc, button, duration, timeout)): + raise ValueError( + "loc, button, duration, and timeout are not supported for action='cancel'" + ) + result = get_desktop().pointer_cancel() + if result["button"] is None: + return "Released all mouse buttons; no tracked button was held." + return f"Released all mouse buttons and cleared held {result['button']} state." diff --git a/tests/test_pointer_controller.py b/tests/test_pointer_controller.py new file mode 100644 index 00000000..f499668f --- /dev/null +++ b/tests/test_pointer_controller.py @@ -0,0 +1,298 @@ +from collections.abc import Callable + +import pytest + +from windows_mcp.desktop import pointer +from windows_mcp.desktop.pointer import PointerController +from windows_mcp.desktop.service import Desktop + + +class FakeTimer: + def __init__(self, interval: float, function: Callable[[], None]) -> None: + self.interval = interval + self.function = function + self.daemon = False + self.started = False + self.cancelled = False + + def start(self) -> None: + self.started = True + + def cancel(self) -> None: + self.cancelled = True + + def fire(self) -> None: + self.function() + + +class TimerFactory: + def __init__(self) -> None: + self.timers: list[FakeTimer] = [] + + def __call__(self, interval: float, function: Callable[[], None]) -> FakeTimer: + timer = FakeTimer(interval, function) + self.timers.append(timer) + return timer + + +class FailingTimerFactory: + def __call__(self, interval: float, function: Callable[[], None]) -> FakeTimer: + raise RuntimeError("timer setup failed") + + +def _mouse_calls(monkeypatch: pytest.MonkeyPatch) -> list[tuple[object, ...]]: + calls: list[tuple[object, ...]] = [] + + def record(name: str) -> Callable: + return lambda *args, **kwargs: calls.append((name, *args, kwargs)) + + monkeypatch.setattr(pointer.uia, "PressMouse", record("press-left")) + monkeypatch.setattr(pointer.uia, "RightPressMouse", record("press-right")) + monkeypatch.setattr(pointer.uia, "MiddlePressMouse", record("press-middle")) + monkeypatch.setattr(pointer.uia, "ReleaseMouse", record("release-left")) + monkeypatch.setattr(pointer.uia, "RightReleaseMouse", record("release-right")) + monkeypatch.setattr(pointer.uia, "MiddleReleaseMouse", record("release-middle")) + monkeypatch.setattr(pointer.uia, "MoveTo", record("move")) + monkeypatch.setattr(pointer.uia, "MoveToDuration", record("move-duration")) + return calls + + +@pytest.mark.parametrize("button", ["left", "right", "middle"]) +def test_pointer_down_and_up_use_matching_button( + button: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _mouse_calls(monkeypatch) + timers = TimerFactory() + controller = PointerController(timer_factory=timers) + + down_result = controller.down([10, 20], button=button, timeout="12.5") + up_result = controller.up(button) + + assert down_result == { + "action": "down", + "button": button, + "loc": [10, 20], + "timeout": 12.5, + } + assert up_result == {"action": "up", "button": button} + assert [call[0] for call in calls] == [f"press-{button}", f"release-{button}"] + assert timers.timers[0].interval == 12.5 + assert timers.timers[0].daemon is True + assert timers.timers[0].started is True + assert timers.timers[0].cancelled is True + assert controller.held_button is None + + +def test_pointer_rejects_second_down_without_extra_input( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _mouse_calls(monkeypatch) + controller = PointerController(timer_factory=TimerFactory()) + controller.down([1, 2]) + + with pytest.raises(RuntimeError, match="already held"): + controller.down([3, 4], button="right") + + assert [call[0] for call in calls] == ["press-left"] + assert controller.held_button == "left" + + +def test_pointer_move_supports_immediate_and_duration_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _mouse_calls(monkeypatch) + controller = PointerController(timer_factory=TimerFactory()) + controller.down([1, 2]) + + immediate = controller.move([3, 4]) + bounded = controller.move([5, 6], duration="0.25") + + assert immediate["duration"] is None + assert bounded["duration"] == 0.25 + assert [call[0] for call in calls] == ["press-left", "move", "move-duration"] + assert controller.held_button == "left" + + +def test_pointer_move_requires_held_button_before_input( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _mouse_calls(monkeypatch) + controller = PointerController(timer_factory=TimerFactory()) + + with pytest.raises(RuntimeError, match="no mouse button is held"): + controller.move([3, 4]) + + assert calls == [] + + +def test_pointer_up_rejects_button_mismatch_without_releasing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _mouse_calls(monkeypatch) + controller = PointerController(timer_factory=TimerFactory()) + controller.down([1, 2], button="right") + + with pytest.raises(RuntimeError, match="right mouse button is held"): + controller.up("left") + + assert [call[0] for call in calls] == ["press-right"] + assert controller.held_button == "right" + + +def test_pointer_cancel_is_repeatable_and_releases_every_button( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _mouse_calls(monkeypatch) + controller = PointerController(timer_factory=TimerFactory()) + controller.down([1, 2], button="middle") + + first = controller.cancel() + second = controller.cancel() + + assert first == {"action": "cancel", "button": "middle"} + assert second == {"action": "cancel", "button": None} + assert [call[0] for call in calls] == [ + "press-middle", + "release-left", + "release-right", + "release-middle", + "release-left", + "release-right", + "release-middle", + ] + assert controller.held_button is None + + +def test_pointer_timeout_releases_all_buttons_and_ignores_stale_timer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _mouse_calls(monkeypatch) + timers = TimerFactory() + controller = PointerController(timer_factory=timers) + controller.down([1, 2]) + first_timer = timers.timers[0] + controller.up() + controller.down([3, 4], button="right") + + first_timer.fire() + assert controller.held_button == "right" + assert [call[0] for call in calls] == ["press-left", "release-left", "press-right"] + + timers.timers[1].fire() + assert controller.held_button is None + assert [call[0] for call in calls][-3:] == [ + "release-left", + "release-right", + "release-middle", + ] + + +def test_pointer_move_failure_releases_tracked_button( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _mouse_calls(monkeypatch) + controller = PointerController(timer_factory=TimerFactory()) + controller.down([1, 2]) + monkeypatch.setattr( + pointer.uia, "MoveTo", lambda *args, **kwargs: (_ for _ in ()).throw(OSError("move failed")) + ) + + with pytest.raises(OSError, match="move failed"): + controller.move([3, 4]) + + assert [call[0] for call in calls] == ["press-left", "release-left"] + assert controller.held_button is None + + +def test_pointer_timer_setup_failure_releases_pressed_button( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _mouse_calls(monkeypatch) + controller = PointerController(timer_factory=FailingTimerFactory()) + + with pytest.raises(RuntimeError, match="timer setup failed"): + controller.down([1, 2]) + + assert [call[0] for call in calls] == ["press-left", "release-left"] + assert controller.held_button is None + + +@pytest.mark.parametrize( + ("method", "value", "message"), + [ + ("down", [1], "loc"), + ("down", [1, True], "integers"), + ("down", [1.0, 2], "integers"), + ("timeout", True, "finite"), + ("timeout", 0, "greater than 0"), + ("timeout", 121, "at most 120"), + ("duration", False, "finite"), + ("duration", -1, "between 0 and 10"), + ("duration", 11, "between 0 and 10"), + ], +) +def test_pointer_rejects_invalid_values_before_input( + method: str, + value: object, + message: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = _mouse_calls(monkeypatch) + controller = PointerController(timer_factory=TimerFactory()) + + with pytest.raises(ValueError, match=message): + if method == "down": + controller.down(value) + elif method == "timeout": + controller.down([1, 2], timeout=value) + else: + controller.down([1, 2]) + calls.clear() + controller.move([3, 4], duration=value) + + assert calls == [] + + +class FakePointerController: + def __init__(self) -> None: + self.calls: list[tuple[object, ...]] = [] + + def down(self, *args: object) -> dict[str, object]: + self.calls.append(("down", *args)) + return {"action": "down"} + + def move(self, *args: object) -> dict[str, object]: + self.calls.append(("move", *args)) + return {"action": "move"} + + def up(self, *args: object) -> dict[str, object]: + self.calls.append(("up", *args)) + return {"action": "up"} + + def cancel(self) -> dict[str, object]: + self.calls.append(("cancel",)) + return {"action": "cancel"} + + def close(self) -> None: + self.calls.append(("close",)) + + +def test_desktop_delegates_pointer_lifecycle() -> None: + desktop = Desktop.__new__(Desktop) + controller = FakePointerController() + desktop._pointer = controller + + desktop.pointer_down([1, 2], "right", 10) + desktop.pointer_move([3, 4], 0.2) + desktop.pointer_up("right") + desktop.pointer_cancel() + desktop.close() + + assert controller.calls == [ + ("down", [1, 2], "right", 10), + ("move", [3, 4], 0.2), + ("up", "right"), + ("cancel",), + ("close",), + ] diff --git a/tests/test_pointer_tool.py b/tests/test_pointer_tool.py new file mode 100644 index 00000000..ac8b4c09 --- /dev/null +++ b/tests/test_pointer_tool.py @@ -0,0 +1,130 @@ +import asyncio +from collections.abc import Callable + +import pytest + +from windows_mcp.tools.pointer import register + + +class FakeMCP: + def __init__(self) -> None: + self.tools: dict[str, Callable] = {} + self.options: dict[str, dict[str, object]] = {} + + def tool(self, *, name: str, **kwargs: object) -> Callable: + self.options[name] = kwargs + + def decorator(func: Callable) -> Callable: + self.tools[name] = func + return func + + return decorator + + +class FakeDesktop: + def __init__(self) -> None: + self.calls: list[tuple[object, ...]] = [] + + def pointer_down(self, loc, button, timeout) -> dict[str, object]: + self.calls.append(("down", loc, button, timeout)) + return {"action": "down", "loc": list(loc), "button": button, "timeout": timeout} + + def pointer_move(self, loc, duration) -> dict[str, object]: + self.calls.append(("move", loc, duration)) + return {"action": "move", "loc": list(loc), "button": "left", "duration": duration} + + def pointer_up(self, button) -> dict[str, object]: + self.calls.append(("up", button)) + return {"action": "up", "button": button or "left"} + + def pointer_cancel(self) -> dict[str, object]: + self.calls.append(("cancel",)) + return {"action": "cancel", "button": None} + + +def _registered(desktop: FakeDesktop) -> tuple[FakeMCP, Callable]: + mcp = FakeMCP() + register(mcp, get_desktop=lambda: desktop, get_analytics=lambda: None) + return mcp, mcp.tools["Pointer"] + + +def test_pointer_registers_one_consolidated_tool() -> None: + mcp, _ = _registered(FakeDesktop()) + + assert set(mcp.tools) == {"Pointer"} + assert mcp.options["Pointer"]["annotations"].destructiveHint is True + assert mcp.options["Pointer"]["annotations"].idempotentHint is False + + +def test_pointer_down_accepts_json_loc_and_defaults() -> None: + desktop = FakeDesktop() + _, tool = _registered(desktop) + + result = asyncio.run(tool(action="down", loc="[10, 20]")) + + assert result == ( + "Pressed left mouse button at (10,20); automatic release timeout is 30 seconds." + ) + assert desktop.calls == [("down", (10, 20), "left", 30.0)] + + +def test_pointer_move_accepts_bounded_duration() -> None: + desktop = FakeDesktop() + _, tool = _registered(desktop) + + result = asyncio.run(tool(action=" MOVE ", loc=[30, 40], duration="0.25")) + + assert result == "Moved held left mouse button to (30,40) over 0.250 seconds." + assert desktop.calls == [("move", (30, 40), 0.25)] + + +def test_pointer_up_asserts_optional_button() -> None: + desktop = FakeDesktop() + _, tool = _registered(desktop) + + result = asyncio.run(tool(action="up", button="right")) + + assert result == "Released right mouse button." + assert desktop.calls == [("up", "right")] + + +def test_pointer_cancel_has_no_extra_arguments() -> None: + desktop = FakeDesktop() + _, tool = _registered(desktop) + + result = asyncio.run(tool(action="cancel")) + + assert result == "Released all mouse buttons; no tracked button was held." + assert desktop.calls == [("cancel",)] + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"action": "invalid"}, "action must be one of"), + ({"action": "down"}, "loc is required"), + ({"action": "down", "loc": [1, 2], "duration": 1}, "only supported.*move"), + ({"action": "move", "loc": [1, 2], "button": "left"}, "only supported.*down"), + ({"action": "move", "loc": [1, 2], "timeout": 1}, "only supported.*down"), + ({"action": "up", "loc": [1, 2]}, "not supported.*up"), + ({"action": "up", "duration": 1}, "not supported.*up"), + ({"action": "cancel", "button": "left"}, "not supported.*cancel"), + ({"action": "down", "loc": "not json"}, "JSON list"), + ({"action": "down", "loc": [1, True]}, "integers"), + ({"action": "down", "loc": [1, 2], "button": ""}, "button must be"), + ({"action": "down", "loc": [1, 2], "button": "primary"}, "button must be"), + ({"action": "down", "loc": [1, 2], "timeout": True}, "finite"), + ({"action": "move", "loc": [1, 2], "duration": "nan"}, "finite"), + ], +) +def test_pointer_rejects_invalid_combinations_before_desktop( + kwargs: dict[str, object], + message: str, +) -> None: + desktop = FakeDesktop() + _, tool = _registered(desktop) + + with pytest.raises(ValueError, match=message): + asyncio.run(tool(**kwargs)) + + assert desktop.calls == []