Skip to content
Closed
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,12 @@ 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` to release only the tracked button for recovery;
held buttons are automatically released after 30 seconds by default (maximum
120 seconds). If Windows rejects an automatic release, it retries twice and
retains the tracked state so `cancel` can retry manually. 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 @@ -123,6 +123,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. Cancel and timeout release only the tracked button. 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
26 changes: 17 additions & 9 deletions src/windows_mcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,19 +340,27 @@ async def lifespan(app: FastMCP):
logger.debug("Server started, entering main loop")
yield
finally:
await notifier.close()
try:
controller.stop()
await notifier.close()
finally:
try:
control_overlay.stop()
# Release Pointer before the controller's ledger retry.
if desktop:
try:
desktop.close()
except Exception:
logger.exception("Failed to release desktop input during shutdown")
controller.stop()
finally:
logger.debug("Shutting down: stopping watchdog and analytics")
if watchdog:
watchdog.stop()
if analytics:
await analytics.close()
control_loop = None
try:
control_overlay.stop()
finally:
logger.debug("Shutting down: stopping watchdog and analytics")
if watchdog:
watchdog.stop()
if analytics:
await analytics.close()
control_loop = None

_mcp = FastMCP(name="windows-mcp", instructions=instructions, lifespan=lifespan)
_mcp.add_middleware(ControlToolGate(controller, notifier))
Expand Down
33 changes: 27 additions & 6 deletions src/windows_mcp/desktop/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,14 @@ def _tick_locked(self, now: float) -> bool:
"ai" if self._active_calls or now < self._lease_until else "ready"
)
if self._state == "ai" and not self._active_calls and now >= self._lease_until:
pointer_hold = self.input_ledger.pointer_hold_state()
if pointer_hold is not None:
if pointer_hold:
# Pointer's bounded timer or physical takeover ends this
# cross-call hold; the ordinary 15-second lease does not.
return False
self._fail_open()
return self._set_locked("unavailable")
if now - self._last_physical_event < 10.0:
self._last_user = self._last_physical_event
return self._set_locked("user")
Expand Down Expand Up @@ -250,12 +258,25 @@ def begin_call(self, name: str) -> int:
status["state"], "CONTROL_UNAVAILABLE"
)
raise ControlBlocked(code, status)
try:
self.input_ledger.enable() # Any failed release keeps the lease closed.
except RuntimeError:
self._fail_open()
self._set_locked("unavailable")
raise ControlBlocked("CONTROL_UNAVAILABLE", self._snapshot_locked(now)) from None
pointer_hold = self.input_ledger.pointer_hold_state()
if pointer_hold is not None:
if self._state != "ai" or not pointer_hold:
self._fail_open()
self._set_locked("unavailable")
raise ControlBlocked("CONTROL_UNAVAILABLE", self._snapshot_locked(now))
if name != "Pointer":
raise ControlBlocked("AI_INPUT_HELD", self._snapshot_locked(now))
# The existing AI lease already enabled the ledger. Do not
# call enable(), which correctly rejects an outstanding hold.
else:
try:
self.input_ledger.enable() # Failed releases keep the lease closed.
except RuntimeError:
self._fail_open()
self._set_locked("unavailable")
raise ControlBlocked(
"CONTROL_UNAVAILABLE", self._snapshot_locked(now)
) from None
changed = self._set_locked("ai") or changed
self._active_calls += 1
token = self._generation
Expand Down
49 changes: 43 additions & 6 deletions src/windows_mcp/desktop/control_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ class InputUnavailable(ToolError):
class InputLedger:
def __init__(self) -> None:
self._lock = threading.RLock()
self._holds: dict[str, tuple[Callable[[], None], Callable[[], bool]]] = {}
# The optional receipt binds a cross-call hold to one exact gesture.
self._holds: dict[
str, tuple[Callable[[], None], Callable[[], bool], str | None, object | None]
] = {}
self._blocked = False

def block_new(self) -> None:
Expand All @@ -33,36 +36,70 @@ def press(
send_down: Callable[[], None],
send_up: Callable[[], None],
physical_down_delivered: Callable[[], bool],
*,
owner: str | None = None,
receipt: object | None = None,
) -> None:
"""Record before injection: a send routine may inject and then raise."""
if (owner is None) != (receipt is None):
raise ValueError("owner and receipt must be provided together")
with self._lock:
if self._blocked:
raise InputUnavailable("AI input is unavailable")
if name in self._holds:
raise RuntimeError(f"AI input already held: {name}")
self._holds[name] = (send_up, physical_down_delivered)
self._holds[name] = (send_up, physical_down_delivered, owner, receipt)
try:
# Keep the lock through injection so a watchdog cannot send
# up before the matching down has actually reached Windows.
send_down()
except BaseException:
try:
self.release(name)
self.release(name, owner=owner, receipt=receipt)
except Exception:
pass # Keep the entry for shutdown's second attempt.
raise

def release(self, name: str) -> None:
def release(
self, name: str, *, owner: str | None = None, receipt: object | None = None
) -> bool:
"""Release this hold only if an optional gesture receipt still matches."""
if (owner is None) != (receipt is None):
raise ValueError("owner and receipt must be provided together")
with self._lock:
entry = self._holds.get(name)
if entry is None:
return
send_up, physical_down_delivered = entry
return False
send_up, physical_down_delivered, held_owner, held_receipt = entry
if owner is not None and (held_owner != owner or held_receipt is not receipt):
return False
# If a physical down reached the app after takeover, its own up
# will clear the state. Injecting another up would steal the key.
if not physical_down_delivered():
send_up()
self._holds.pop(name, None)
return True

def owns(self, name: str, owner: str, receipt: object) -> bool:
"""Check whether an exact gesture still owns its injected down."""
with self._lock:
entry = self._holds.get(name)
return entry is not None and entry[2] == owner and entry[3] is receipt

def pending_entries(self) -> tuple[tuple[str, str | None], ...]:
"""Return a compact ownership snapshot for the desktop control gate."""
with self._lock:
return tuple((name, entry[2]) for name, entry in self._holds.items())

def pointer_hold_state(self) -> bool | None:
"""None: no hold; True: one Pointer mouse hold; False: unsafe hold."""
with self._lock:
if not self._holds:
return None
if len(self._holds) != 1:
return False
name, entry = next(iter(self._holds.items()))
return name in {"mouse:left", "mouse:right", "mouse:middle"} and entry[2] == "Pointer"

def release_all(self) -> None:
with self._lock:
Expand Down
Loading