Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,7 @@ MCP Client can access the following tools to interact with Windows:
- `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.
- `DisplayInventory`: Read display layout, work areas, effective DPI, and scale metadata.
- `Screenshot`: Fast screenshot-first desktop capture with cursor position, active/open windows, and an image. Skips UI tree extraction for speed and should be the default first call when you mainly need visual context. Supports `display=[0]` or `display=[0,1]` using zero-based active Windows display indices. After capture, a brief orange-red glowing border is drawn inside the captured area as a visual confirmation (set `WINDOWS_MCP_DISABLE_FLASH=1` to disable).
- `Snapshot`: Full desktop state capture for workflows that need interactive element ids, scrollable regions, or `use_dom=True` browser extraction. Supports `use_vision=True` for including screenshots and `display=[0]` or `display=[0,1]` using zero-based active Windows display indices.
- `App`: To launch an application from the start menu, resize or move the window and switch between apps.
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 @@ -3,6 +3,7 @@
from windows_mcp.tools import (
app,
clipboard,
display,
filesystem,
input,
multi,
Expand All @@ -16,6 +17,7 @@

_MODULES = [
app,
display,
shell,
filesystem,
snapshot,
Expand Down
66 changes: 66 additions & 0 deletions src/windows_mcp/tools/display.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""DisplayInventory tool - read-only display and DPI metadata."""

import json
from collections.abc import Callable
from typing import Any

from fastmcp import Context
from mcp.types import ToolAnnotations
from windows_mcp.infrastructure import with_analytics


def _rect_to_dict(rect: Any) -> dict[str, int] | None:
if rect is None:
return None
return {
"left": rect.left,
"top": rect.top,
"right": rect.right,
"bottom": rect.bottom,
"width": rect.width(),
"height": rect.height(),
}


def register(
mcp: Any,
*,
get_desktop: Callable[[], Any],
get_analytics: Callable[[], Any],
) -> None:
@mcp.tool(
name="DisplayInventory",
description=(
"Read active display layout and DPI metadata. Reports display index, device name, "
"monitor/work-area bounds, resolution, orientation, primary flag, effective DPI, "
"and scale."
),
annotations=ToolAnnotations(
title="DisplayInventory",
readOnlyHint=True,
destructiveHint=False,
idempotentHint=True,
openWorldHint=False,
),
)
@with_analytics(get_analytics(), "DisplayInventory-Tool")
def display_inventory_tool(ctx: Context = None) -> str:
displays = get_desktop().get_displays()
payload = {
"displays": [
{
"index": display.index,
"device": display.device_name,
"primary": display.primary,
"bounds": _rect_to_dict(display.rect),
"work_area": _rect_to_dict(getattr(display, "work_rect", None)),
"resolution": f"{display.rect.width()}x{display.rect.height()}",
"orientation": getattr(display, "orientation", None),
"effective_dpi": getattr(display, "effective_dpi", None),
"scale": getattr(display, "scale", None),
}
for display in displays
],
"count": len(displays),
}
return json.dumps(payload, indent=2)
Comment thread
zengfanfan marked this conversation as resolved.
Outdated
Comment thread
zengfanfan marked this conversation as resolved.
Outdated
89 changes: 89 additions & 0 deletions src/windows_mcp/uia/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,10 @@ class DisplayInfo:
device_name: str
rect: "Rect"
primary: bool
work_rect: "Rect | None" = None
effective_dpi: int | None = None
scale: float | None = None
orientation: str | None = None


class _MonitorInfoExW(ctypes.Structure):
Expand All @@ -672,6 +676,78 @@ class _DisplayDeviceW(ctypes.Structure):
]


def _get_system_dpi() -> int | None:
try:
dpi = int(ctypes.windll.user32.GetDpiForSystem())
if dpi > 0:
return dpi
except Exception:
pass

hdc = None
try:
hdc = ctypes.windll.user32.GetDC(0)
if hdc:
dpi = int(ctypes.windll.gdi32.GetDeviceCaps(hdc, 88)) # LOGPIXELSX
if dpi > 0:
return dpi
except Exception:
pass
finally:
if hdc:
try:
ctypes.windll.user32.ReleaseDC(0, hdc)
except Exception:
pass
return None


def _dpi_metadata(dpi: int | None) -> tuple[int | None, float | None]:
if dpi is None:
return None, None
return dpi, round(dpi / 96, 6)


def _get_monitor_effective_dpi(hMonitor: int) -> tuple[int | None, float | None]:
try:
dpi_x = ctypes.c_uint()
dpi_y = ctypes.c_uint()
result = ctypes.windll.shcore.GetDpiForMonitor(
ctypes.c_void_p(hMonitor),
0,
Comment thread
zengfanfan marked this conversation as resolved.
Outdated
ctypes.byref(dpi_x),
ctypes.byref(dpi_y),
)
if result == 0 and dpi_x.value > 0:
return _dpi_metadata(int(dpi_x.value))
except Exception:
pass
return _dpi_metadata(_get_system_dpi())


def _get_display_orientation(device_name: str, rect: "Rect") -> str:
dev_mode_size = 220
dm_size_offset = 68
dm_pels_width_offset = 172
dm_pels_height_offset = 176
dev_mode = bytearray(dev_mode_size)
dev_mode[dm_size_offset : dm_size_offset + 2] = struct.pack("<H", dev_mode_size)
c_dev_mode = (ctypes.c_byte * dev_mode_size).from_buffer(dev_mode)
try:
if ctypes.windll.user32.EnumDisplaySettingsW(
device_name,
ctypes.wintypes.DWORD(-1),
c_dev_mode,
):
width = struct.unpack_from("<I", dev_mode, dm_pels_width_offset)[0]
height = struct.unpack_from("<I", dev_mode, dm_pels_height_offset)[0]
if width > 0 and height > 0:
return "landscape" if width >= height else "portrait"
except Exception:
pass
return "landscape" if rect.width() >= rect.height() else "portrait"


def _active_display_indices() -> Dict[str, int]:
DISPLAY_DEVICE_ATTACHED_TO_DESKTOP = 0x00000001
display_indices: dict[str, int] = {}
Expand Down Expand Up @@ -729,6 +805,12 @@ def MonitorCallback(
info.rcMonitor.right,
info.rcMonitor.bottom,
)
work_rect = Rect(
info.rcWork.left,
info.rcWork.top,
info.rcWork.right,
info.rcWork.bottom,
)
device_name = info.szDevice
index = display_indices.get(device_name.upper(), next_fallback_index())
primary = bool(info.dwFlags & 1)
Expand All @@ -739,16 +821,23 @@ def MonitorCallback(
lprcMonitor.contents.right,
lprcMonitor.contents.bottom,
)
work_rect = None
index = next_fallback_index()
device_name = f"DISPLAY{index}"
primary = False
effective_dpi, scale = _get_monitor_effective_dpi(hMonitor)
orientation = _get_display_orientation(device_name, rect)

displays.append(
DisplayInfo(
index=index,
device_name=device_name,
rect=rect,
primary=primary,
work_rect=work_rect,
effective_dpi=effective_dpi,
scale=scale,
orientation=orientation,
)
)
return 1
Expand Down
105 changes: 105 additions & 0 deletions tests/test_display_inventory_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import asyncio
import json
from types import SimpleNamespace
from collections.abc import Callable

import pytest

from windows_mcp.tools import display as display_tool_module
from windows_mcp.uia import DisplayInfo, Rect, core


class FakeMCP:
def __init__(self) -> None:
self.tools: dict[str, Callable] = {}

def tool(self, *, name: str, **kwargs: object) -> Callable:
def decorator(func: Callable) -> Callable:
self.tools[name] = func
return func

return decorator


class FakeDesktop:
def get_displays(self) -> list[DisplayInfo]:
return [
DisplayInfo(
index=0,
device_name="\\\\.\\DISPLAY1",
rect=Rect(0, 0, 1920, 1080),
primary=True,
work_rect=Rect(0, 0, 1920, 1040),
effective_dpi=144,
scale=1.5,
orientation="landscape",
)
]


def test_display_inventory_returns_display_dpi_metadata() -> None:
mcp = FakeMCP()
display_tool_module.register(mcp, get_desktop=FakeDesktop, get_analytics=lambda: None)

result = json.loads(asyncio.run(mcp.tools["DisplayInventory"]()))

assert result == {
"displays": [
{
"index": 0,
"device": "\\\\.\\DISPLAY1",
"primary": True,
"bounds": {
"left": 0,
"top": 0,
"right": 1920,
"bottom": 1080,
"width": 1920,
"height": 1080,
},
"work_area": {
"left": 0,
"top": 0,
"right": 1920,
"bottom": 1040,
"width": 1920,
"height": 1040,
},
"resolution": "1920x1080",
"orientation": "landscape",
"effective_dpi": 144,
"scale": 1.5,
}
],
"count": 1,
}


def test_monitor_dpi_falls_back_to_system_dpi(monkeypatch: pytest.MonkeyPatch) -> None:
shcore = SimpleNamespace(GetDpiForMonitor=lambda *args: 1)
user32 = SimpleNamespace(GetDpiForSystem=lambda: 120)
monkeypatch.setattr(core.ctypes, "windll", SimpleNamespace(shcore=shcore, user32=user32))

assert core._get_monitor_effective_dpi(123) == (120, 1.25)


def test_display_orientation_uses_current_mode(monkeypatch: pytest.MonkeyPatch) -> None:
def enum_display_settings(device_name: str, mode: object, dev_mode: object) -> int:
core.ctypes.memmove(
core.ctypes.addressof(dev_mode) + 172,
core.struct.pack("<II", 1080, 1920),
8,
)
return 1

user32 = SimpleNamespace(EnumDisplaySettingsW=enum_display_settings)
monkeypatch.setattr(core.ctypes, "windll", SimpleNamespace(user32=user32))

assert core._get_display_orientation("\\\\.\\DISPLAY1", Rect(0, 0, 1080, 1920)) == "portrait"
Comment thread
zengfanfan marked this conversation as resolved.


def test_display_orientation_falls_back_to_bounds(monkeypatch: pytest.MonkeyPatch) -> None:
user32 = SimpleNamespace(EnumDisplaySettingsW=lambda *args: 0)
monkeypatch.setattr(core.ctypes, "windll", SimpleNamespace(user32=user32))

assert core._get_display_orientation("DISPLAY0", Rect(0, 0, 1920, 1080)) == "landscape"