diff --git a/src/index.ts b/src/index.ts index 3ad957d..0d6a805 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,10 @@ import { registerAudioAppTools } from './tools/audio_apps.js'; import { registerPowerTools } from './tools/power.js'; import { registerNetworkTools } from './tools/network.js'; import { registerDriveTools } from './tools/drives.js'; +import { registerDisplayTools } from './tools/display.js'; +import { registerWindowTools } from './tools/window.js'; +import { registerClipboardTools } from './tools/clipboard.js'; +import { registerNotificationTools } from './tools/notification.js'; const server = new McpServer({ name: 'mcp_windows', @@ -41,6 +45,12 @@ registerPowerTools(server); registerNetworkTools(server); registerDriveTools(server); +// v1.2 — Desktop Automation +registerDisplayTools(server); +registerWindowTools(server); +registerClipboardTools(server); +registerNotificationTools(server); + async function main(): Promise { const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/tools/clipboard.ts b/src/tools/clipboard.ts new file mode 100644 index 0000000..05e3a80 --- /dev/null +++ b/src/tools/clipboard.ts @@ -0,0 +1,138 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_clipboard_get (#16), windows_clipboard_set (#17) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerClipboardTools(server: McpServer): void { + server.tool( + 'windows_clipboard_get', + 'Read clipboard contents: text, file list, or image (returned as base64).', + {}, + async () => { + // PowerShell must run in STA mode for clipboard access + const ps = ` +powershell.exe -NoProfile -STA -Command { + Add-Type -AssemblyName System.Windows.Forms + $data = [System.Windows.Forms.Clipboard]::GetDataObject() + if (-not $data) { Write-Output '{"type":"empty","content":"Clipboard is empty"}'; return } + + # Check for files + if ($data.ContainsFileDropList()) { + $files = [System.Windows.Forms.Clipboard]::GetFileDropList() + $list = @() + foreach ($f in $files) { $list += $f } + $json = @{ type = 'files'; content = $list } | ConvertTo-Json -Compress + Write-Output $json + return + } + + # Check for image + if ($data.ContainsImage()) { + $img = [System.Windows.Forms.Clipboard]::GetImage() + $ms = New-Object System.IO.MemoryStream + $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) + $b64 = [Convert]::ToBase64String($ms.ToArray()) + $ms.Dispose() + $img.Dispose() + $json = @{ type = 'image'; content = $b64 } | ConvertTo-Json -Compress + Write-Output $json + return + } + + # Text + if ($data.ContainsText()) { + $text = [System.Windows.Forms.Clipboard]::GetText() + $json = @{ type = 'text'; content = $text } | ConvertTo-Json -Compress + Write-Output $json + return + } + + Write-Output '{"type":"unknown","content":"Clipboard contains unsupported format"}' +}`; + + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + try { + const data = JSON.parse(result.stdout); + + if (data.type === 'image') { + return { + content: [ + { type: 'text' as const, text: 'Clipboard contains an image:' }, + { type: 'image' as const, data: data.content, mimeType: 'image/png' }, + ], + }; + } + + if (data.type === 'files') { + const files = Array.isArray(data.content) ? data.content : [data.content]; + return { + content: [{ type: 'text', text: `Clipboard contains ${files.length} file(s):\n${files.join('\n')}` }], + }; + } + + return { + content: [{ type: 'text', text: data.content || '(empty)' }], + }; + } catch { + return { content: [{ type: 'text', text: result.stdout || '(empty clipboard)' }] }; + } + }, + ); + + server.tool( + 'windows_clipboard_set', + 'Set clipboard contents: text, file list, or clear.', + { + text: z.string().optional().describe('Text to copy to clipboard'), + files: z.array(z.string()).optional().describe('File paths to copy to clipboard'), + clear: z.boolean().optional().describe('Clear the clipboard'), + }, + async ({ text, files, clear }) => { + if (clear) { + const result = await runPowerShell(` +powershell.exe -NoProfile -STA -Command { + Add-Type -AssemblyName System.Windows.Forms + [System.Windows.Forms.Clipboard]::Clear() + "Clipboard cleared" +}`); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + + if (text) { + // Escape for PowerShell here-string + const escaped = text.replace(/'/g, "''"); + const result = await runPowerShell(` +powershell.exe -NoProfile -STA -Command { + Add-Type -AssemblyName System.Windows.Forms + [System.Windows.Forms.Clipboard]::SetText('${escaped}') + "Copied $([System.Windows.Forms.Clipboard]::GetText().Length) chars to clipboard" +}`); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + + if (files && files.length > 0) { + const pathList = files.map(f => `$fc.Add('${f.replace(/'/g, "''")}')`).join('; '); + const result = await runPowerShell(` +powershell.exe -NoProfile -STA -Command { + Add-Type -AssemblyName System.Windows.Forms + $fc = New-Object System.Collections.Specialized.StringCollection + ${pathList} + [System.Windows.Forms.Clipboard]::SetFileDropList($fc) + "Copied $($fc.Count) file(s) to clipboard" +}`); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + + return { content: [{ type: 'text', text: 'Provide text, files, or clear.' }], isError: true }; + }, + ); +} diff --git a/src/tools/display.ts b/src/tools/display.ts new file mode 100644 index 0000000..4f5c3ac --- /dev/null +++ b/src/tools/display.ts @@ -0,0 +1,285 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_display_get (#9), windows_display_set (#10), windows_screenshot (#11) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { tmpdir } from 'node:os'; + +export function registerDisplayTools(server: McpServer): void { + server.tool( + 'windows_display_get', + 'Get display configuration: resolution, refresh rate, scaling, multi-monitor layout, HDR status.', + {}, + async () => { + const ps = ` +Add-Type -AssemblyName System.Windows.Forms + +$screens = [System.Windows.Forms.Screen]::AllScreens +$monitors = Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams -ErrorAction SilentlyContinue +$videoCtrl = Get-CimInstance Win32_VideoController + +$i = 0 +$screens | ForEach-Object { + $s = $_ + $vc = $videoCtrl | Where-Object { $_.Name -match $s.DeviceName -or $true } | Select-Object -First 1 + $dpiScale = [math]::Round(($s.Bounds.Width / $s.WorkingArea.Width) * 100, 0) + + # Try to get real scaling from registry + $regScale = $null + try { + $regPath = "HKCU:\\Control Panel\\Desktop\\PerMonitorSettings" + if (Test-Path $regPath) { + $regScale = (Get-ChildItem $regPath -ErrorAction SilentlyContinue | Select-Object -Index $i | Get-ItemProperty -ErrorAction SilentlyContinue).DpiValue + } + } catch {} + + [PSCustomObject]@{ + Index = $i + Name = $s.DeviceName + Primary = $s.Primary + Resolution = "$($s.Bounds.Width)x$($s.Bounds.Height)" + RefreshRate = if ($vc) { "$($vc.CurrentRefreshRate) Hz" } else { 'Unknown' } + Scaling = if ($regScale) { "$($regScale)%" } else { 'System default' } + Position = "($($s.Bounds.X), $($s.Bounds.Y))" + WorkArea = "$($s.WorkingArea.Width)x$($s.WorkingArea.Height)" + BitsPerPixel = if ($vc) { $vc.CurrentBitsPerPixel } else { $null } + } + $i++ +} | ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + const displays = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = displays.map((d: { Index: number; Name: string; Primary: boolean; Resolution: string; RefreshRate: string; Scaling: string; Position: string; WorkArea: string }) => + [ + `Monitor ${d.Index}: ${d.Name}${d.Primary ? ' (Primary)' : ''}`, + ` Resolution: ${d.Resolution} @ ${d.RefreshRate}`, + ` Scaling: ${d.Scaling}`, + ` Position: ${d.Position}`, + ` Work area: ${d.WorkArea}`, + ].join('\n'), + ); + + return { content: [{ type: 'text', text: lines.join('\n\n') }] }; + }, + ); + + server.tool( + 'windows_display_set', + 'Change display settings: resolution, brightness.', + { + resolution: z.string().optional().describe('Resolution as "WIDTHxHEIGHT" (e.g. "1920x1080")'), + brightness: z.number().min(0).max(100).optional().describe('Screen brightness 0-100 (laptops only)'), + }, + async ({ resolution, brightness }) => { + const results: string[] = []; + + if (brightness !== undefined) { + const ps = ` +try { + $monitors = Get-CimInstance -Namespace root/WMI -ClassName WmiMonitorBrightnessMethods -ErrorAction Stop + $monitors | Invoke-CimMethod -MethodName WmiSetBrightness -Arguments @{Timeout=1; Brightness=${brightness}} -ErrorAction Stop + "Brightness set to ${brightness}%" +} catch { + "Error: Brightness control not available (requires laptop/integrated display). $_" +}`; + const r = await runPowerShell(ps); + results.push(r.stdout || r.stderr); + } + + if (resolution) { + const match = resolution.match(/^(\d+)x(\d+)$/); + if (!match) { + results.push('Invalid resolution format. Use WIDTHxHEIGHT (e.g. 1920x1080)'); + } else { + const ps = ` +Add-Type @' +using System; +using System.Runtime.InteropServices; + +public class DisplaySettings { + [DllImport("user32.dll")] + public static extern int EnumDisplaySettings(string deviceName, int modeNum, ref DEVMODE devMode); + [DllImport("user32.dll")] + public static extern int ChangeDisplaySettings(ref DEVMODE devMode, int flags); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] + public struct DEVMODE { + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string dmDeviceName; + public short dmSpecVersion; + public short dmDriverVersion; + public short dmSize; + public short dmDriverExtra; + public int dmFields; + public int dmPositionX; + public int dmPositionY; + public int dmDisplayOrientation; + public int dmDisplayFixedOutput; + public short dmColor; + public short dmDuplex; + public short dmYResolution; + public short dmTTOption; + public short dmCollate; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string dmFormName; + public short dmLogPixels; + public int dmBitsPerPel; + public int dmPelsWidth; + public int dmPelsHeight; + public int dmDisplayFlags; + public int dmDisplayFrequency; + } + + public static string SetResolution(int width, int height) { + DEVMODE dm = new DEVMODE(); + dm.dmSize = (short)Marshal.SizeOf(typeof(DEVMODE)); + if (EnumDisplaySettings(null, -1, ref dm) != 0) { + dm.dmPelsWidth = width; + dm.dmPelsHeight = height; + dm.dmFields = 0x80000 | 0x100000; // DM_PELSWIDTH | DM_PELSHEIGHT + int result = ChangeDisplaySettings(ref dm, 0); + if (result == 0) return "Resolution changed to " + width + "x" + height; + return "Failed to change resolution (code: " + result + "). Resolution may not be supported."; + } + return "Failed to enumerate display settings."; + } +} +'@ -ErrorAction Stop +[DisplaySettings]::SetResolution(${match[1]}, ${match[2]})`; + const r = await runPowerShell(ps); + results.push(r.stdout || r.stderr); + } + } + + if (results.length === 0) { + return { content: [{ type: 'text', text: 'No changes specified. Provide resolution or brightness.' }], isError: true }; + } + + return { content: [{ type: 'text', text: results.join('\n') }] }; + }, + ); + + server.tool( + 'windows_screenshot', + 'Capture a screenshot of the screen, a specific window, or a region. Returns as base64 image.', + { + target: z.enum(['screen', 'window', 'region']).default('screen').describe('What to capture'), + monitor: z.number().default(0).describe('Monitor index (for screen capture)'), + window_title: z.string().optional().describe('Window title substring (for window capture)'), + x: z.number().optional().describe('Region X (for region capture)'), + y: z.number().optional().describe('Region Y'), + width: z.number().optional().describe('Region width'), + height: z.number().optional().describe('Region height'), + save_path: z.string().optional().describe('Save to file instead of returning base64'), + }, + async ({ target, monitor, window_title, x, y, width, height, save_path }) => { + const outPath = save_path ? resolve(save_path) : resolve(tmpdir(), `screenshot_${Date.now()}.png`); + + let ps: string; + + if (target === 'window' && window_title) { + ps = ` +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +Add-Type @' +using System; +using System.Runtime.InteropServices; +public class Win32Window { + [DllImport("user32.dll")] public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + [StructLayout(LayoutKind.Sequential)] + public struct RECT { public int Left, Top, Right, Bottom; } +} +'@ +$target = '${window_title.replace(/'/g, "''")}' +$found = $null +[Win32Window]::EnumWindows({ + param($hWnd, $lParam) + $sb = New-Object System.Text.StringBuilder 256 + [Win32Window]::GetWindowText($hWnd, $sb, 256) | Out-Null + $title = $sb.ToString() + if ($title -like "*$target*") { $script:found = $hWnd; return $false } + return $true +}, [IntPtr]::Zero) | Out-Null + +if (-not $found) { throw "Window not found: $target" } + +$rect = New-Object Win32Window+RECT +[Win32Window]::GetWindowRect($found, [ref]$rect) | Out-Null +$w = $rect.Right - $rect.Left +$h = $rect.Bottom - $rect.Top +$bmp = New-Object System.Drawing.Bitmap($w, $h) +$g = [System.Drawing.Graphics]::FromImage($bmp) +$g.CopyFromScreen($rect.Left, $rect.Top, 0, 0, [System.Drawing.Size]::new($w, $h)) +$g.Dispose() +$bmp.Save('${outPath.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png) +$bmp.Dispose() +"saved"`; + } else if (target === 'region' && x !== undefined && y !== undefined && width && height) { + ps = ` +Add-Type -AssemblyName System.Drawing +$bmp = New-Object System.Drawing.Bitmap(${width}, ${height}) +$g = [System.Drawing.Graphics]::FromImage($bmp) +$g.CopyFromScreen(${x}, ${y}, 0, 0, [System.Drawing.Size]::new(${width}, ${height})) +$g.Dispose() +$bmp.Save('${outPath.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png) +$bmp.Dispose() +"saved"`; + } else { + // Full screen + ps = ` +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +$screen = [System.Windows.Forms.Screen]::AllScreens[${monitor}] +$bounds = $screen.Bounds +$bmp = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height) +$g = [System.Drawing.Graphics]::FromImage($bmp) +$g.CopyFromScreen($bounds.X, $bounds.Y, 0, 0, $bounds.Size) +$g.Dispose() +$bmp.Save('${outPath.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png) +$bmp.Dispose() +"saved"`; + } + + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + if (save_path) { + return { content: [{ type: 'text', text: `Screenshot saved: ${outPath}` }] }; + } + + // Return as base64 image + try { + const data = await readFile(outPath); + // Clean up temp file + await import('node:fs/promises').then(fs => fs.unlink(outPath)).catch(() => {}); + return { + content: [{ + type: 'image' as const, + data: data.toString('base64'), + mimeType: 'image/png', + }], + }; + } catch { + return { content: [{ type: 'text', text: `Screenshot captured at ${outPath} but failed to read back.` }] }; + } + }, + ); +} diff --git a/src/tools/notification.ts b/src/tools/notification.ts new file mode 100644 index 0000000..9feefcd --- /dev/null +++ b/src/tools/notification.ts @@ -0,0 +1,50 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_notification_send (#20) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerNotificationTools(server: McpServer): void { + server.tool( + 'windows_notification_send', + 'Send a Windows toast notification with title, body, and optional icon.', + { + title: z.string().describe('Notification title'), + body: z.string().describe('Notification body text'), + icon: z.string().optional().describe('Path to icon file (optional)'), + sound: z.boolean().default(true).describe('Play notification sound'), + }, + async ({ title, body, icon, sound }) => { + const iconParam = icon + ? `$notify.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon('${icon.replace(/'/g, "''")}');` + : `$notify.Icon = [System.Drawing.SystemIcons]::Information;`; + + const ps = ` +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$notify = New-Object System.Windows.Forms.NotifyIcon +${iconParam} +$notify.Visible = $true +$notify.BalloonTipTitle = '${title.replace(/'/g, "''")}' +$notify.BalloonTipText = '${body.replace(/'/g, "''")}' +$notify.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Info +$notify.ShowBalloonTip(5000) + +# Keep alive briefly so notification displays +Start-Sleep -Milliseconds 100 +$notify.Dispose() +"Notification sent: ${title.replace(/"/g, '\\"')}"`; + + const result = await runPowerShell(ps, { timeout: 10000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +} diff --git a/src/tools/window.ts b/src/tools/window.ts new file mode 100644 index 0000000..745b027 --- /dev/null +++ b/src/tools/window.ts @@ -0,0 +1,211 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_window_list (#14), windows_window_control (#15) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +const WIN32_TYPES = ` +Add-Type @' +using System; +using System.Text; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +public class WindowManager { + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); + [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); + [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool IsZoomed(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + + public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [StructLayout(LayoutKind.Sequential)] + public struct RECT { public int Left, Top, Right, Bottom; } + + public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); + public static readonly IntPtr HWND_NOTOPMOST = new IntPtr(-2); + public const uint SWP_NOMOVE = 0x0002; + public const uint SWP_NOSIZE = 0x0001; + public const uint WM_CLOSE = 0x0010; +} +'@ -ErrorAction SilentlyContinue +`; + +export function registerWindowTools(server: McpServer): void { + server.tool( + 'windows_window_list', + 'List all visible windows with title, PID, process name, position, size, and state.', + { + filter: z.string().optional().describe('Filter by window title (substring)'), + }, + async ({ filter }) => { + const filterClause = filter + ? `.Where({ $_.Title -like '*${filter.replace(/'/g, "''")}*' })` + : ''; + + const ps = ` +${WIN32_TYPES} +$windows = [System.Collections.Generic.List[PSObject]]::new() +$zOrder = 0 +[WindowManager]::EnumWindows({ + param($hWnd, $lParam) + if (-not [WindowManager]::IsWindowVisible($hWnd)) { return $true } + $len = [WindowManager]::GetWindowTextLength($hWnd) + if ($len -eq 0) { return $true } + $sb = New-Object System.Text.StringBuilder($len + 1) + [WindowManager]::GetWindowText($hWnd, $sb, $sb.Capacity) | Out-Null + $title = $sb.ToString() + if (-not $title) { return $true } + + $pid = [uint32]0 + [WindowManager]::GetWindowThreadProcessId($hWnd, [ref]$pid) | Out-Null + $proc = Get-Process -Id $pid -ErrorAction SilentlyContinue + + $rect = New-Object WindowManager+RECT + [WindowManager]::GetWindowRect($hWnd, [ref]$rect) | Out-Null + + $state = 'Normal' + if ([WindowManager]::IsIconic($hWnd)) { $state = 'Minimized' } + elseif ([WindowManager]::IsZoomed($hWnd)) { $state = 'Maximized' } + + $script:windows.Add([PSCustomObject]@{ + ZOrder = $script:zOrder++ + Title = $title + PID = $pid + Process = if ($proc) { $proc.ProcessName } else { '?' } + X = $rect.Left + Y = $rect.Top + Width = $rect.Right - $rect.Left + Height = $rect.Bottom - $rect.Top + State = $state + }) + return $true +}, [IntPtr]::Zero) | Out-Null + +$windows ${filterClause} | ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + if (!result.stdout) { + return { content: [{ type: 'text', text: 'No visible windows found.' }] }; + } + + const windows = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = windows.map((w: { ZOrder: number; Title: string; PID: number; Process: string; X: number; Y: number; Width: number; Height: number; State: string }) => { + const stateIcon = w.State === 'Minimized' ? '[-]' : w.State === 'Maximized' ? '[+]' : '[ ]'; + return `${stateIcon} ${String(w.PID).padStart(6)} ${w.Process.padEnd(20).slice(0, 20)} ${String(w.Width).padStart(5)}x${String(w.Height).padEnd(5)} (${w.X},${w.Y}) ${w.Title.slice(0, 60)}`; + }); + + const header = `Sta ${'PID'.padStart(6)} ${'Process'.padEnd(20)} ${'Size'.padStart(11)} Pos Title`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(110)}\n${lines.join('\n')}\n\n${windows.length} windows` }], + }; + }, + ); + + server.tool( + 'windows_window_control', + 'Move, resize, minimize, maximize, restore, close, or focus a window.', + { + title: z.string().optional().describe('Window title (substring match)'), + pid: z.number().optional().describe('Process ID'), + action: z.enum(['minimize', 'maximize', 'restore', 'close', 'focus', 'move', 'resize', 'topmost']).describe('Action'), + x: z.number().optional().describe('X position (for move)'), + y: z.number().optional().describe('Y position (for move)'), + width: z.number().optional().describe('Width (for resize)'), + height: z.number().optional().describe('Height (for resize)'), + topmost: z.boolean().optional().describe('Set always-on-top (for topmost action)'), + }, + async ({ title, pid, action, x, y, width, height, topmost }) => { + if (!title && !pid) { + return { content: [{ type: 'text', text: 'Provide either title or pid.' }], isError: true }; + } + + const findWindow = title + ? ` +$target = '${title.replace(/'/g, "''")}' +$hWnd = [IntPtr]::Zero +[WindowManager]::EnumWindows({ + param($h, $l) + $sb = New-Object System.Text.StringBuilder 256 + [WindowManager]::GetWindowText($h, $sb, 256) | Out-Null + if ($sb.ToString() -like "*$target*" -and [WindowManager]::IsWindowVisible($h)) { + $script:hWnd = $h; return $false + } + return $true +}, [IntPtr]::Zero) | Out-Null +if ($hWnd -eq [IntPtr]::Zero) { throw "Window not found: $target" }` + : ` +$proc = Get-Process -Id ${pid} -ErrorAction Stop +$hWnd = $proc.MainWindowHandle +if ($hWnd -eq [IntPtr]::Zero) { throw "Process ${pid} has no visible window" }`; + + let actionCode: string; + switch (action) { + case 'minimize': + actionCode = `[WindowManager]::ShowWindow($hWnd, 6) | Out-Null; "Minimized"`; + break; + case 'maximize': + actionCode = `[WindowManager]::ShowWindow($hWnd, 3) | Out-Null; "Maximized"`; + break; + case 'restore': + actionCode = `[WindowManager]::ShowWindow($hWnd, 9) | Out-Null; "Restored"`; + break; + case 'close': + actionCode = `[WindowManager]::PostMessage($hWnd, [WindowManager]::WM_CLOSE, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null; "Close message sent"`; + break; + case 'focus': + actionCode = `[WindowManager]::ShowWindow($hWnd, 9) | Out-Null; [WindowManager]::SetForegroundWindow($hWnd) | Out-Null; "Focused"`; + break; + case 'move': + if (x === undefined || y === undefined) { + return { content: [{ type: 'text', text: 'Move requires x and y.' }], isError: true }; + } + actionCode = ` +$rect = New-Object WindowManager+RECT +[WindowManager]::GetWindowRect($hWnd, [ref]$rect) | Out-Null +$w = $rect.Right - $rect.Left; $h = $rect.Bottom - $rect.Top +[WindowManager]::MoveWindow($hWnd, ${x}, ${y}, $w, $h, $true) | Out-Null +"Moved to (${x}, ${y})"`; + break; + case 'resize': + if (!width || !height) { + return { content: [{ type: 'text', text: 'Resize requires width and height.' }], isError: true }; + } + actionCode = ` +$rect = New-Object WindowManager+RECT +[WindowManager]::GetWindowRect($hWnd, [ref]$rect) | Out-Null +[WindowManager]::MoveWindow($hWnd, $rect.Left, $rect.Top, ${width}, ${height}, $true) | Out-Null +"Resized to ${width}x${height}"`; + break; + case 'topmost': + const insertAfter = topmost !== false ? '[WindowManager]::HWND_TOPMOST' : '[WindowManager]::HWND_NOTOPMOST'; + actionCode = `[WindowManager]::SetWindowPos($hWnd, ${insertAfter}, 0, 0, 0, 0, [WindowManager]::SWP_NOMOVE -bor [WindowManager]::SWP_NOSIZE) | Out-Null; "Topmost: ${topmost !== false}"`; + break; + } + + const ps = `${WIN32_TYPES}\n${findWindow}\n${actionCode}`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +}