// --- // id: win-app-screenshots // name: win-app-screenshots.cs // description: Capture a PNG of a running Windows application window, and give the picture to the model. // longDescription: Windows only. It finds a top-level window from a process name, a process id, an executable path, or a window title, and copies its pixels to a PNG. PrintWindow works while another window covers the target, and a screen copy is the fallback. The tool returns the picture to the model, and the path of the saved file. // tags: // - windows // - screenshots // - images // - utilities // status: stable // version: 1.0.0 // author: XAKPC Dev Labs // license: MIT // --- #:package Microsoft.Extensions.Hosting@10.0.11 #:package ModelContextProtocol@2.2.0 #:package SixLabors.ImageSharp@3.1.12 #:property PublishAot=false using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; using System.Text; var builder = Host.CreateApplicationBuilder(args); builder.Logging.AddConsole(consoleLogOptions => { // Configure all logs to go to stderr consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; }); // Register the MCP server builder.Services .AddMcpServer() .WithStdioServerTransport() .WithToolsFromAssembly(); // Build and run the MCP Server Application await builder.Build().RunAsync(); //====== TOOLS ====== public record WindowInfo(string Process, int Id, string Handle, string Title); public record WindowListResult(int Count, WindowInfo[] Windows); [McpServerToolType] public static class ScreenshotTools { [McpServerTool, Description("List the visible windows of a running Windows application, so a capture can pick one")] public static WindowListResult ListAppWindows( [Description("Process name, with or without .exe")] string? name = null, [Description("Process id")] int? id = null, [Description("Full path of the executable")] string? path = null, [Description("Part of the window title. The match is not case sensitive")] string? title = null) { Capture.RequireWindows(); Capture.PrepareProcess(); var windows = Capture.FindTargets(name, id, path, title) .Select(w => new WindowInfo(w.Process, w.Id, $"0x{w.Handle.ToInt64():X}", w.Title)) .ToArray(); return new WindowListResult(windows.Length, windows); } [McpServerTool, Description("Capture a window of a running Windows application as a PNG, and return the picture with its file path")] public static CallToolResult CaptureAppWindow( [Description("Process name, with or without .exe")] string? name = null, [Description("Process id")] int? id = null, [Description("Full path of the executable")] string? path = null, [Description("Part of the window title. The match is not case sensitive")] string? title = null, [Description("Output PNG path. The default is a file with the process name and the time, in the temp folder")] string? output = null, [Description("Seconds to wait for the window to appear. 0 means that the window must exist now")] int waitSec = 0, [Description("Milliseconds to wait after the window comes to the front, before the capture")] int delayMs = 800, [Description("How to get the pixels: Auto, PrintWindow, or Screen")] string method = "Auto", [Description("Do not bring the window to the front")] bool noActivate = false, [Description("Capture the client area only, with no title bar and no border")] bool client = false, [Description("Downscale the picture that the model sees to this width. The saved file keeps its full size. 0 removes the limit")] int maxWidth = 1400) { Capture.RequireWindows(); Capture.PrepareProcess(); var found = Capture.WaitForWindows(name, id, path, title, waitSec); return Capture.Shoot(found[0], found.Count, output, delayMs, method, noActivate, client, maxWidth); } } internal static class Capture { internal record Target(string Process, int Id, IntPtr Handle, string Title); private static int dpiReady; internal static void RequireWindows() { if (!OperatingSystem.IsWindows()) { throw new PlatformNotSupportedException( "win-app-screenshots reads the pixels with the Windows GDI, so it runs on Windows only."); } } /// /// Tells Windows that this process works in physical pixels. Without this /// call the coordinates are wrong on a display with a scale above 100 /// percent, and the picture is cut. -4 is /// DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2. /// /// The call belongs to the first tool call, and not to the start of the /// host. A DllImport resolves at the first call, so the server starts and /// lists its tools on every platform, and only a capture fails away from /// Windows. The test harness builds and probes each server on Linux. /// internal static void PrepareProcess() { if (Interlocked.Exchange(ref dpiReady, 1) == 1) { return; } // An old Windows has no such entry point, and the process then keeps // the awareness that it has. try { Native.SetProcessDpiAwarenessContext(new IntPtr(-4)); } catch { } } internal static List FindTargets(string? name, int? id, string? path, string? title) { if (name is null && id is null && path is null && title is null) { throw new ArgumentException("Give a name, an id, a path, or a title to choose the application."); } // A null map means that no process filter is set, so every window is a // candidate and the title alone chooses. var wanted = WantedProcesses(name, id, path); var found = new List(); foreach (var handle in Native.TopLevelWindows()) { var processId = Native.ProcessIdOf(handle); if (wanted is not null && !wanted.ContainsKey(processId)) { continue; } var windowTitle = Native.TitleOf(handle); if (title is not null && !windowTitle.Contains(title, StringComparison.OrdinalIgnoreCase)) { continue; } found.Add(new Target(ProcessNameOf(processId, wanted), (int)processId, handle, windowTitle)); } return found; } internal static List WaitForWindows(string? name, int? id, string? path, string? title, int waitSec) { var deadline = DateTime.UtcNow.AddSeconds(Math.Clamp(waitSec, 0, 600)); var found = FindTargets(name, id, path, title); while (found.Count == 0 && DateTime.UtcNow < deadline) { Thread.Sleep(500); found = FindTargets(name, id, path, title); } if (found.Count == 0) { throw new InvalidOperationException( "No window matched. Is the application running? Raise waitSec to wait for the start, " + "or call list_app_windows to see the windows that exist now."); } return found; } internal static CallToolResult Shoot(Target target, int matchCount, string? output, int delayMs, string method, bool noActivate, bool client, int maxWidth) { var mode = method.Trim().ToLowerInvariant() switch { "" or "auto" => "auto", "printwindow" => "printwindow", "screen" => "screen", _ => throw new ArgumentException($"Unknown method '{method}'. Use Auto, PrintWindow, or Screen.") }; var handle = target.Handle; if (Native.IsIconic(handle)) { // A minimized window holds no pixels. Restore it for every method. Native.ShowWindow(handle, Native.ShowRestore); Thread.Sleep(400); } var isForeground = false; if (!noActivate) { isForeground = Native.ForceForeground(handle); } Thread.Sleep(Math.Clamp(delayMs, 0, 30000)); // The measurements. GetWindowRect adds the invisible resize border of // the compositor, which gives a grey edge, so the true bounds come from // DWM and the PrintWindow picture needs a cut. Native.GetWindowRect(handle, out var windowRect); var hasFrame = Native.DwmGetWindowAttribute( handle, Native.ExtendedFrameBounds, out var frame, Marshal.SizeOf()) == 0; if (!hasFrame) { // Windows 7 and some remote sessions run with no compositor. frame = windowRect; } Native.GetClientRect(handle, out var clientRect); var clientOrigin = default(Native.POINT); Native.ClientToScreen(handle, ref clientOrigin); var left = client ? clientOrigin.X : frame.Left; var top = client ? clientOrigin.Y : frame.Top; var width = client ? clientRect.Right - clientRect.Left : frame.Right - frame.Left; var height = client ? clientRect.Bottom - clientRect.Top : frame.Bottom - frame.Top; if (width <= 0 || height <= 0) { throw new InvalidOperationException("The window has no size. It is not drawn yet."); } var used = "PrintWindow"; var image = mode == "screen" ? null : WithPrintWindow(handle, client, windowRect, frame, clientRect, width, height); if (image is null) { if (mode == "printwindow") { throw new InvalidOperationException( "PrintWindow gave an empty picture for this window. Call again with method Screen."); } image = FromScreen(left, top, width, height); used = "Screen"; } using (image) { var file = ResolveOutput(output, target.Process); image.SaveAsPng(file); var note = new StringBuilder(); note.Append($"{target.Process} ({target.Id}) '{target.Title}' "); note.Append($"{image.Width}x{image.Height} by {used}"); note.Append($"\nSaved: {file}"); if (matchCount > 1) { note.Append($"\n{matchCount} windows matched, and the first one is in the picture. "); note.Append("Call list_app_windows to see them all."); } if (!noActivate && !isForeground && used == "Screen") { note.Append("\nThe window did not come to the front, so another window can be in the picture."); } using var inline = Downscale(image, maxWidth); using var buffer = new MemoryStream(); inline.SaveAsPng(buffer); return new CallToolResult { Content = [ // FromBytes and not the Data property. Data holds the bytes // of the base64 text, so a raw PNG there reaches the client // as broken characters. ImageContentBlock.FromBytes(buffer.ToArray(), "image/png"), new TextContentBlock { Text = note.ToString() } ] }; } } /// /// Asks the window to draw itself. This works while another window covers /// it, and it needs no focus. A null answer means that the window gave /// nothing, and the caller must copy the display instead. /// private static Image? WithPrintWindow(IntPtr handle, bool client, Native.RECT windowRect, Native.RECT frame, Native.RECT clientRect, int width, int height) { var flags = Native.PrintFullContent; int fullWidth; int fullHeight; if (client) { flags |= Native.PrintClientOnly; fullWidth = clientRect.Right - clientRect.Left; fullHeight = clientRect.Bottom - clientRect.Top; } else { fullWidth = windowRect.Right - windowRect.Left; fullHeight = windowRect.Bottom - windowRect.Top; } if (fullWidth <= 0 || fullHeight <= 0) { return null; } var pixels = Native.Render(fullWidth, fullHeight, hdc => Native.PrintWindow(handle, hdc, flags)); if (pixels is null || IsBlank(pixels, fullWidth, fullHeight)) { return null; } var image = Image.LoadPixelData(pixels, fullWidth, fullHeight); if (client) { return image; } // Cut the invisible border away, so the result holds the same area that // the screen method gives. var cut = Rectangle.Intersect( new Rectangle(frame.Left - windowRect.Left, frame.Top - windowRect.Top, width, height), image.Bounds); if (cut.Width <= 0 || cut.Height <= 0) { return image; } image.Mutate(x => x.Crop(cut)); return image; } /// /// Copies the pixels of the display. It is exact for what the eye sees, but /// it gives the window above the target when the target is behind another. /// private static Image FromScreen(int left, int top, int width, int height) { var screen = Native.GetDC(IntPtr.Zero); try { var pixels = Native.Render(width, height, hdc => Native.BitBlt(hdc, 0, 0, width, height, screen, left, top, Native.SrcCopy | Native.CaptureBlt)); if (pixels is null) { throw new InvalidOperationException("The screen copy gave no pixels."); } return Image.LoadPixelData(pixels, width, height); } finally { Native.ReleaseDC(IntPtr.Zero, screen); } } /// /// PrintWindow can answer true and still give an empty picture. Some /// windows that draw with the graphics card behave like this. A picture /// with one colour only is empty. /// private static bool IsBlank(byte[] pixels, int width, int height) { var first = BitConverter.ToUInt32(pixels, 0); var stepX = Math.Max(1, width / 24); var stepY = Math.Max(1, height / 24); for (var y = 0; y < height; y += stepY) { for (var x = 0; x < width; x += stepX) { if (BitConverter.ToUInt32(pixels, ((y * width) + x) * 4) != first) { return false; } } } return true; } /// /// Always gives a new picture, so the caller can dispose it while the full /// size original stays alive. /// private static Image Downscale(Image source, int maxWidth) { var copy = source.Clone(); if (maxWidth > 0 && copy.Width > maxWidth) { // A height of 0 keeps the ratio of the sides. copy.Mutate(x => x.Resize(maxWidth, 0)); } return copy; } private static string ResolveOutput(string? output, string process) { var file = output; if (string.IsNullOrWhiteSpace(file)) { // Path.GetTempPath and not the TEMP variable: the lint layer asks // that each variable that the code reads is in the envVars list, // and the page would then show an env block that nobody must set. var folder = Path.Combine(Path.GetTempPath(), "win-app-screenshots"); file = Path.Combine(folder, $"{process}-{DateTime.Now:yyyyMMdd-HHmmss}.png"); } var full = Path.GetFullPath(file); var parent = Path.GetDirectoryName(full); if (!string.IsNullOrEmpty(parent)) { Directory.CreateDirectory(parent); } return full; } private static Dictionary? WantedProcesses(string? name, int? id, string? path) { if (name is null && id is null && path is null) { return null; } var wanted = new Dictionary(); if (id is not null) { try { using var one = Process.GetProcessById(id.Value); wanted[(uint)one.Id] = one.ProcessName; } catch (ArgumentException) { // The process is gone. An empty map matches no window, and the // caller reports that nothing matched. } return wanted; } if (name is not null) { var clean = name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? name[..^4] : name; foreach (var process in Process.GetProcessesByName(clean)) { using (process) { wanted[(uint)process.Id] = process.ProcessName; } } return wanted; } var full = Path.GetFullPath(path!); foreach (var process in Process.GetProcesses()) { using (process) { if (string.Equals(ExecutablePath(process), full, StringComparison.OrdinalIgnoreCase)) { wanted[(uint)process.Id] = process.ProcessName; } } } return wanted; } private static string ProcessNameOf(uint processId, Dictionary? known) { if (known is not null && known.TryGetValue(processId, out var value)) { return value; } try { using var process = Process.GetProcessById((int)processId); return process.ProcessName; } catch { return "unknown"; } } private static string? ExecutablePath(Process process) { // A protected process refuses this read, and throws. try { return process.MainModule?.FileName; } catch { return null; } } } internal static class Native { [StructLayout(LayoutKind.Sequential)] internal struct RECT { public int Left; public int Top; public int Right; public int Bottom; } [StructLayout(LayoutKind.Sequential)] internal struct POINT { public int X; public int Y; } [StructLayout(LayoutKind.Sequential)] internal struct BITMAPINFOHEADER { public uint biSize; public int biWidth; public int biHeight; public ushort biPlanes; public ushort biBitCount; public uint biCompression; public uint biSizeImage; public int biXPelsPerMeter; public int biYPelsPerMeter; public uint biClrUsed; public uint biClrImportant; } internal delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); // DWMWA_EXTENDED_FRAME_BOUNDS, SW_RESTORE, and GW_OWNER. internal const int ExtendedFrameBounds = 9; internal const int ShowRestore = 9; internal const uint GwOwner = 4; // PW_CLIENTONLY and PW_RENDERFULLCONTENT. The second flag makes a window // that draws with the graphics card give its true pixels. internal const uint PrintClientOnly = 1; internal const uint PrintFullContent = 2; // SRCCOPY, and CAPTUREBLT for the layered windows above the target. internal const uint SrcCopy = 0x00CC0020; internal const uint CaptureBlt = 0x40000000; [DllImport("user32.dll")] internal static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); [DllImport("user32.dll")] internal static extern bool IsWindowVisible(IntPtr window); [DllImport("user32.dll")] internal static extern IntPtr GetWindow(IntPtr window, uint command); [DllImport("user32.dll")] internal static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); [DllImport("user32.dll")] internal static extern int GetWindowTextLength(IntPtr window); [DllImport("user32.dll", CharSet = CharSet.Unicode)] internal static extern int GetWindowText(IntPtr window, StringBuilder text, int max); [DllImport("user32.dll")] internal static extern bool GetWindowRect(IntPtr window, out RECT rectangle); [DllImport("user32.dll")] internal static extern bool GetClientRect(IntPtr window, out RECT rectangle); [DllImport("user32.dll")] internal static extern bool ClientToScreen(IntPtr window, ref POINT point); [DllImport("user32.dll")] internal static extern bool SetForegroundWindow(IntPtr window); [DllImport("user32.dll")] internal static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] internal static extern bool BringWindowToTop(IntPtr window); [DllImport("user32.dll")] internal static extern bool AttachThreadInput(uint from, uint to, bool attach); [DllImport("user32.dll")] internal static extern bool ShowWindow(IntPtr window, int command); [DllImport("user32.dll")] internal static extern bool IsIconic(IntPtr window); [DllImport("user32.dll")] internal static extern bool PrintWindow(IntPtr window, IntPtr deviceContext, uint flags); [DllImport("user32.dll")] internal static extern IntPtr GetDC(IntPtr window); [DllImport("user32.dll")] internal static extern int ReleaseDC(IntPtr window, IntPtr deviceContext); [DllImport("user32.dll")] internal static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport("kernel32.dll")] internal static extern uint GetCurrentThreadId(); [DllImport("dwmapi.dll")] internal static extern int DwmGetWindowAttribute(IntPtr window, int attribute, out RECT rectangle, int size); [DllImport("gdi32.dll")] internal static extern IntPtr CreateCompatibleDC(IntPtr deviceContext); [DllImport("gdi32.dll")] internal static extern IntPtr CreateDIBSection(IntPtr deviceContext, ref BITMAPINFOHEADER header, uint usage, out IntPtr bits, IntPtr section, uint offset); [DllImport("gdi32.dll")] internal static extern IntPtr SelectObject(IntPtr deviceContext, IntPtr handle); [DllImport("gdi32.dll")] internal static extern bool BitBlt(IntPtr target, int x, int y, int width, int height, IntPtr source, int sourceX, int sourceY, uint operation); [DllImport("gdi32.dll")] internal static extern bool DeleteObject(IntPtr handle); [DllImport("gdi32.dll")] internal static extern bool DeleteDC(IntPtr deviceContext); [DllImport("gdi32.dll")] internal static extern bool GdiFlush(); /// /// Makes a 32 bit top-down DIB, runs the draw action against its device /// context, and gives the pixels back as BGRA bytes. CreateDIBSection hands /// the pixel pointer back, so no GetDIBits call is necessary. /// internal static byte[]? Render(int width, int height, Func draw) { var header = new BITMAPINFOHEADER { biSize = (uint)Marshal.SizeOf(), biWidth = width, // A negative height gives the rows from the top. A positive height // gives them in the other order, and the picture is upside down. biHeight = -height, biPlanes = 1, biBitCount = 32, biCompression = 0 }; var memoryDc = CreateCompatibleDC(IntPtr.Zero); if (memoryDc == IntPtr.Zero) { return null; } var section = IntPtr.Zero; var previous = IntPtr.Zero; try { section = CreateDIBSection(memoryDc, ref header, 0, out var bits, IntPtr.Zero, 0); if (section == IntPtr.Zero || bits == IntPtr.Zero) { return null; } previous = SelectObject(memoryDc, section); if (!draw(memoryDc)) { return null; } // GDI holds the drawing in a batch. Read the memory only after the // batch reaches the device context. GdiFlush(); var pixels = new byte[width * height * 4]; Marshal.Copy(bits, pixels, 0, pixels.Length); // GDI leaves the alpha byte at zero, and every pixel of the PNG // would then be fully transparent. Make each one opaque. for (var i = 3; i < pixels.Length; i += 4) { pixels[i] = 255; } return pixels; } finally { if (previous != IntPtr.Zero) { SelectObject(memoryDc, previous); } if (section != IntPtr.Zero) { DeleteObject(section); } DeleteDC(memoryDc); } } /// /// Brings a window to the front. Windows stops a background process from /// taking the focus, so the input queue of this thread joins the queue of /// the window that has the focus now. The call is then legal. /// internal static bool ForceForeground(IntPtr window) { var current = GetForegroundWindow(); if (current == window) { return true; } var currentThread = current == IntPtr.Zero ? 0 : GetWindowThreadProcessId(current, out _); var thisThread = GetCurrentThreadId(); var attached = currentThread != 0 && currentThread != thisThread && AttachThreadInput(thisThread, currentThread, true); try { BringWindowToTop(window); SetForegroundWindow(window); } finally { if (attached) { AttachThreadInput(thisThread, currentThread, false); } } return GetForegroundWindow() == window; } internal static List TopLevelWindows() { var found = new List(); EnumWindows((window, parameter) => { // A visible window with a title and no owner is a real window. This // drops the hidden helper windows of the framework. if (IsWindowVisible(window) && GetWindow(window, GwOwner) == IntPtr.Zero && GetWindowTextLength(window) > 0) { found.Add(window); } return true; }, IntPtr.Zero); return found; } internal static uint ProcessIdOf(IntPtr window) { GetWindowThreadProcessId(window, out var processId); return processId; } internal static string TitleOf(IntPtr window) { var length = GetWindowTextLength(window); if (length <= 0) { return string.Empty; } var text = new StringBuilder(length + 1); GetWindowText(window, text, text.Capacity); return text.ToString(); } }