feat: implement v1.2 desktop automation tools (8 new tools)
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
- tools/display.ts: windows_display_get, windows_display_set, windows_screenshot (#9-11) - tools/window.ts: windows_window_list, windows_window_control (#14, #15) - tools/clipboard.ts: windows_clipboard_get, windows_clipboard_set (#16, #17) - tools/notification.ts: windows_notification_send (#20) Uses Win32 P/Invoke for window management and System.Drawing for screenshots. Clipboard uses STA PowerShell subprocess for COM threading. Total: 31 tools registered (v1.0 + v1.1 + v1.2 complete) Authored-by: Moko Consulting
This commit is contained in:
@@ -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<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* 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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* 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.` }] };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* 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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* 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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user