Skip to content
2 changes: 2 additions & 0 deletions src/windows_mcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
OAuthStore,
build_oauth_routes,
validate_oauth_token,
install_selfpipe_guard,
)
from click.core import ParameterSource
from fastmcp import FastMCP
Expand Down Expand Up @@ -560,6 +561,7 @@ def serve(
stateless_http,
):
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
install_selfpipe_guard()
if transport == Transport.STDIO.value:
os.environ.setdefault("NO_COLOR", "1")
if debug:
Expand Down
50 changes: 49 additions & 1 deletion src/windows_mcp/desktop/screenshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,13 @@ def capture(self, capture_rect: uia.Rect | None) -> Image.Image:

_backend_instances: dict[str, _ScreenshotBackend] = {}

#: Backends that have handed back an unusable frame in this process.
#: On VM/RDP desktops dxcam can initialize successfully and then return empty
#: frames from then on (issue #371). Retrying it on every call just reproduces
#: the same broken capture, so a backend caught doing this is skipped for the
#: rest of the process and the chain moves on to mss.
_degraded_backends: set[str] = set()


def _get_backend(name: str) -> _ScreenshotBackend:
"""Return a cached singleton instance for the given backend *name*."""
Expand All @@ -288,6 +295,30 @@ def _get_backend(name: str) -> _ScreenshotBackend:
return _backend_instances[name]


def _is_usable_capture(image: Image.Image | None) -> bool:
"""Return True if a captured frame is structurally sound enough to encode.

A backend that fails by raising is already handled by the chain below. The
case this catches is the quiet one behind issue #371: on VM/RDP desktops
dxcam can initialize successfully and then return frames carrying no pixel
data, which travel all the way to the client as an undecodable image with no
error anywhere in between.

Only structure is checked, never content -- a legitimately black screen is a
perfectly valid screenshot, and rejecting it would break locked and
screensaver desktops.
"""
if image is None:
return False
if image.width <= 0 or image.height <= 0:
return False
try:
image.load()
except (OSError, ValueError):
return False
return True


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
Expand All @@ -311,17 +342,34 @@ def capture(

# Try each candidate: skip unavailable ones, catch failures and fall through.
for backend_cls in chain:
if backend_cls.name in _degraded_backends:
continue
inst = _get_backend(backend_cls.name)
if not inst.is_available(capture_rect):
continue
try:
return inst.capture(capture_rect), inst.name
image = inst.capture(capture_rect)
except (OSError, RuntimeError, ValueError, IndexError):
logger.warning(
"Screenshot backend '%s' failed; trying next backend",
inst.name,
exc_info=selected != "auto",
)
continue

# A backend can also fail silently, returning a frame with nothing in it.
# Treat that exactly like a raised failure rather than shipping bytes the
# client cannot decode.
if not _is_usable_capture(image):
_degraded_backends.add(inst.name)
logger.warning(
"Screenshot backend '%s' returned an unusable frame; disabling it "
"for this process and trying the next backend",
inst.name,
)
continue

return image, inst.name

# All candidates exhausted — pillow is always present as the last resort.
return _get_backend("pillow").capture(capture_rect), "pillow"
15 changes: 12 additions & 3 deletions src/windows_mcp/desktop/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -882,9 +882,18 @@ def scrape(self, url: str) -> str:
return content

def is_overlay_window(self, element: uia.Control) -> bool:
no_children = len(element.GetChildren()) == 0
is_name = "Overlay" in element.Name.strip()
return no_children or is_name
"""Return True if the window is a decorative overlay rather than a real app window.

"No children" alone is deliberately not enough. When UIA child
enumeration degrades — as it does on Windows ARM64 under x86-emulated
Python — every window looks childless, and treating that as an overlay
filters out the entire desktop and leaves the caller blind. A real app
window always has a title, so require both signals.
"""
name = element.Name.strip()
if "Overlay" in name:
return True
return not name and len(element.GetChildren()) == 0

def get_controls_handles(self, optimized: bool = False):
handles = set()
Expand Down
36 changes: 36 additions & 0 deletions src/windows_mcp/desktop/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
__all__ = [
"resolve_known_folder_guid_path",
"remove_private_use_chars",
"repair_surrogates",
"is_elevated",
]

Expand Down Expand Up @@ -70,3 +71,38 @@ def resolve_known_folder_guid_path(path_text: str) -> str:
def remove_private_use_chars(text: str) -> str:
"""Remove Unicode Private Use Area characters that may cause rendering issues."""
return _PRIVATE_USE_RE.sub('', text)


_SURROGATE_RE = re.compile(r'[\ud800-\udfff]')


def repair_surrogates(text: str) -> str:
"""Combine UTF-16 surrogate pairs into real characters, replacing unpaired ones.

UIA hands back UTF-16 text, and an astral character such as an emoji can
arrive as its raw surrogate pair (U+1F437 as U+D83D U+DC37) instead of a
single code point. Python keeps those surrogates in the str quite happily,
but encoding one to UTF-8 raises UnicodeEncodeError -- so the whole tool
response fails to serialize and the caller gets nothing back at all, over a
single emoji in somebody's window title.

Pair up what can be paired, and replace what cannot with U+FFFD, so a stray
half-character costs one glyph rather than the entire snapshot.
"""
if not _SURROGATE_RE.search(text):
return text

out: list[str] = []
i = 0
end = len(text)
while i < end:
code = ord(text[i])
if 0xD800 <= code <= 0xDBFF and i + 1 < end:
low = ord(text[i + 1])
if 0xDC00 <= low <= 0xDFFF:
out.append(chr(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00)))
i += 2
continue
out.append('\ufffd' if 0xD800 <= code <= 0xDFFF else text[i])
i += 1
return ''.join(out)
2 changes: 2 additions & 0 deletions src/windows_mcp/infrastructure/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
write_config,
)
from windows_mcp.infrastructure.oauth import OAuthStore, build_oauth_routes, validate_oauth_token
from windows_mcp.infrastructure.eventloop import install_selfpipe_guard

__all__ = [
"AuthKeyMiddleware",
Expand All @@ -42,4 +43,5 @@
"OAuthStore",
"build_oauth_routes",
"validate_oauth_token",
"install_selfpipe_guard",
]
90 changes: 90 additions & 0 deletions src/windows_mcp/infrastructure/eventloop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Event-loop hardening for a server that outlives many desktop sessions."""

import logging
import socket
from asyncio.selector_events import BaseSelectorEventLoop

logger = logging.getLogger(__name__)

__all__ = ["install_selfpipe_guard", "SELFPIPE_REBUILD_WARN_AT"]

# Past this many rebuilds the self-pipe is not merely a casualty of the odd
# session transition, so say so once. We keep rebuilding regardless: the loop
# cannot run without it. select() on Windows rejects a call with no descriptors
# registered (WinError 10022), and under the stdio transport the self-pipe is
# often the only one, so unregistering it would take the server down.
SELFPIPE_REBUILD_WARN_AT = 20


def _selfpipe_is_open(loop: BaseSelectorEventLoop) -> bool:
"""Whether the loop's self-pipe still has a live peer.

``_read_from_self`` drains the socket and returns silently at EOF, so the
only way to tell a quiet pipe from a dead one is to look: a healthy
non-blocking socket with nothing buffered raises BlockingIOError, while a
torn-down one peeks an immediate empty read.
"""
try:
return loop._ssock.recv(1, socket.MSG_PEEK) != b""
except BlockingIOError:
return True
except OSError:
return False


def _rebuild_selfpipe(loop: BaseSelectorEventLoop) -> None:
rebuilds = getattr(loop, "_selfpipe_rebuilds", 0) + 1
loop._selfpipe_rebuilds = rebuilds

loop._close_self_pipe()
loop._make_self_pipe()

if rebuilds == SELFPIPE_REBUILD_WARN_AT:
logger.warning(
"The asyncio self-pipe has died %d times; something on this machine "
"keeps tearing down loopback sockets.",
rebuilds,
)
else:
logger.debug("Rebuilt a dead asyncio self-pipe (rebuild #%d)", rebuilds)


def install_selfpipe_guard() -> None:
"""Rebuild asyncio's self-pipe instead of busy-looping when Windows kills it.

asyncio wakes its event loop through a loopback socketpair. A Windows
session or display transition tears that idle pair down, and the loop never
notices: ``BaseSelectorEventLoop._read_from_self`` reads the resulting EOF,
hits its ``break``, and returns without unregistering anything. The socket
stays permanently readable, so ``select`` returns instantly on every pass
and the process pins a full CPU core -- silently, with no exception and no
log line, until it is killed. Windows-MCP is long-lived on a desktop that
gets locked and unlocked all day, so it meets this far more often than most
asyncio programs do.

This is upstream CPython python/cpython#156333, reported there against the
proactor loop; the selector loop we run reaches the same dead end through
``_read_from_self``. Installing the guard is idempotent and a no-op on a
healthy loop.

Related issue: #392
"""
original = BaseSelectorEventLoop._read_from_self
if getattr(original, "_selfpipe_guard", False):
return

def _read_from_self(self: BaseSelectorEventLoop) -> None:
try:
original(self)
except OSError:
# An abortively closed peer surfaces as ConnectionResetError
# straight out of recv(); upstream lets it escape into the loop's
# exception handler and leaves the socket registered anyway.
pass
else:
if _selfpipe_is_open(self):
return
_rebuild_selfpipe(self)

_read_from_self._selfpipe_guard = True
BaseSelectorEventLoop._read_from_self = _read_from_self
12 changes: 11 additions & 1 deletion src/windows_mcp/powershell/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,16 @@ def run_with_graceful_timeout(
# Windows graceful-stop prerequisite: CREATE_NEW_PROCESS_GROUP is required
# so that send_signal(CTRL_BREAK_EVENT) targets the child process group
# rather than the current process (which would cause it to exit).
#
# CREATE_NO_WINDOW suppresses the console the child would otherwise get.
# When the server has no console of its own — the usual case when it runs
# as an MCP extension host — Windows allocates a *new* console for a
# console child, which flashes on screen and steals keyboard focus from
# whatever the user is typing in. Redirecting the streams does not prevent
# the allocation; only this flag does. It composes with the process-group
# flag, so CTRL_BREAK_EVENT and the graceful-stop path are unaffected.
creationflags = kwargs.get("creationflags", 0)
creationflags |= subprocess.CREATE_NEW_PROCESS_GROUP
creationflags |= subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
kwargs["creationflags"] = creationflags

with subprocess.Popen(*popenargs, **kwargs) as process:
Expand Down Expand Up @@ -132,6 +140,7 @@ def run_with_graceful_timeout(
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
creationflags=subprocess.CREATE_NO_WINDOW,
)

try:
Expand All @@ -153,6 +162,7 @@ def run_with_graceful_timeout(
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
creationflags=subprocess.CREATE_NO_WINDOW,
)
raise

Expand Down
14 changes: 13 additions & 1 deletion src/windows_mcp/tools/_snapshot_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from fastmcp.utilities.types import Image
from textwrap import dedent
from windows_mcp.desktop.service import Desktop, Size
from windows_mcp.desktop.utils import remove_private_use_chars
from windows_mcp.desktop.utils import remove_private_use_chars, repair_surrogates


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -163,6 +163,18 @@ def build_snapshot_response(
scrollable_elements = remove_private_use_chars(scrollable_elements)
semantic_tree = remove_private_use_chars(semantic_tree)

# Emoji reach us from UIA as raw UTF-16 surrogate pairs. Left alone they make
# the strict UTF-8 JSON encoder reject the entire response, so every string
# heading into it is repaired -- window titles included, since an emoji in a
# window title is enough to take the whole snapshot down.
interactive_elements = repair_surrogates(interactive_elements)
scrollable_elements = repair_surrogates(scrollable_elements)
semantic_tree = repair_surrogates(semantic_tree)
windows = repair_surrogates(windows)
active_window = repair_surrogates(active_window)
active_desktop = repair_surrogates(active_desktop)
all_desktops = repair_surrogates(all_desktops)

def display_to_string(display):
primary = " primary" if display.primary else ""
return (
Expand Down
Loading