From 28097ae20d8edf77d16c41b841cb67782bb8a4f3 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 25 May 2026 23:48:51 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20v3.0-v3.2=20=E2=80=94=2017?= =?UTF-8?q?=20new=20tools,=2080=20total=20(major=20version=20bump)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3.0 — WSL & Package Management: - tools/wsl.ts: windows_wsl_list, _manage, _exec (#66-68) - tools/winget.ts: windows_winget_search, _manage, _export (#69-71) v3.1 — Storage & System Management: - tools/storage.ts: windows_disk_cleanup, _symlink (#72-73) - tools/system_mgmt.ts: windows_timezone, _features, _smb_shares, _dns_cache (#74-77) v3.2 — Automation & Advanced: - tools/automation.ts: windows_shortcut, _input, _font_list, _sandbox, _screen_capture (#78-82) Version bumped to 3.0.0, CHANGELOG updated, package.json bumped. Test results: 14/14 passed. Authored-by: Moko Consulting --- CHANGELOG.md | 59 ++++++++ package.json | 2 +- src/index.ts | 18 ++- src/tools/automation.ts | 297 +++++++++++++++++++++++++++++++++++++++ src/tools/storage.ts | 141 +++++++++++++++++++ src/tools/system_mgmt.ts | 190 +++++++++++++++++++++++++ src/tools/winget.ts | 100 +++++++++++++ src/tools/wsl.ts | 112 +++++++++++++++ 8 files changed, 917 insertions(+), 2 deletions(-) create mode 100644 src/tools/automation.ts create mode 100644 src/tools/storage.ts create mode 100644 src/tools/system_mgmt.ts create mode 100644 src/tools/winget.ts create mode 100644 src/tools/wsl.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bb6ae8a..c7bd31c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,65 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0] - 2026-05-25 + +### Added + +#### v3.0 -- WSL & Package Management (6 tools) +- `windows_wsl_list` -- List WSL distributions with status and version +- `windows_wsl_manage` -- Start, stop, shutdown, export, import, remove WSL distros +- `windows_wsl_exec` -- Execute commands inside a WSL distribution +- `windows_winget_search` -- Search winget package repository +- `windows_winget_manage` -- Install, upgrade, uninstall, list packages via winget +- `windows_winget_export` -- Export/import winget package lists + +#### v3.1 -- Storage & System Management (6 tools) +- `windows_disk_cleanup` -- Analyze and clean disk space (temp, cache, logs) +- `windows_symlink` -- Create and manage symlinks, junctions, hard links +- `windows_timezone` -- Get/set timezone, list timezones, sync time +- `windows_features` -- Enable/disable Windows optional features +- `windows_smb_shares` -- List local shares, map/unmap network drives +- `windows_dns_cache` -- View, clear DNS cache, resolve domains + +#### v3.2 -- Automation & Advanced (5 tools) +- `windows_shortcut` -- Create and read .lnk shortcut files +- `windows_input` -- Simulate keyboard/mouse (keys, combos, text, clicks, moves) +- `windows_font_list` -- List installed fonts from registry +- `windows_sandbox` -- Check/launch Windows Sandbox with custom config +- `windows_screen_capture` -- Screen recording via ffmpeg (start/stop/status) + +### Changed +- Version bumped to 3.0.0 (80 tools total) + +## [2.0.0] - 2026-05-25 + +### Added + +#### v2.0 -- Connectivity & Hardware (7 tools) +- `windows_bluetooth_get` -- Bluetooth adapter status and paired devices +- `windows_bluetooth_control` -- Enable/disable Bluetooth, disconnect devices +- `windows_wifi_networks` -- Scan Wi-Fi networks, list saved profiles +- `windows_wifi_connect` -- Connect, disconnect, forget Wi-Fi networks +- `windows_usb_devices` -- List USB devices with safe eject +- `windows_printer_list` -- List printers, queue, set default, clear queue +- `windows_hosts_file` -- Read/manage Windows hosts file + +#### v2.1 -- Appearance & Desktop (5 tools) +- `windows_theme_get` -- Dark mode, accent color, wallpaper, taskbar +- `windows_theme_set` -- Set dark mode, wallpaper, transparency, taskbar +- `windows_virtual_desktop` -- List, create, switch, remove virtual desktops +- `windows_focus_mode` -- Get/set Focus Assist / Do Not Disturb +- `windows_default_apps` -- Get default file associations + +#### v2.2 -- Security & Maintenance (7 tools) +- `windows_firewall_get` -- Firewall status and rules +- `windows_firewall_manage` -- Create, enable, disable, delete firewall rules +- `windows_updates` -- Windows Update status and history +- `windows_event_log` -- Read event log entries with filters +- `windows_restore_point` -- List/create System Restore points +- `windows_certificate_list` -- List certificates in certificate store +- `windows_performance_monitor` -- Real-time CPU, RAM, disk, network stats + ## [1.0.0] - 2026-05-25 ### Added diff --git a/package.json b/package.json index d916bfe..4bfd527 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mokoconsulting/mcp_windows", - "version": "1.0.0", + "version": "3.0.0", "description": "MCP server for Windows desktop system operations", "type": "module", "main": "dist/index.js", diff --git a/src/index.ts b/src/index.ts index 083043f..09a7a6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,10 +41,15 @@ import { registerThemeTools } from './tools/theme.js'; import { registerVirtualDesktopTools } from './tools/virtual_desktop.js'; import { registerFirewallTools } from './tools/firewall.js'; import { registerMaintenanceTools } from './tools/maintenance.js'; +import { registerWslTools } from './tools/wsl.js'; +import { registerWingetTools } from './tools/winget.js'; +import { registerStorageTools } from './tools/storage.js'; +import { registerSystemMgmtTools } from './tools/system_mgmt.js'; +import { registerAutomationTools } from './tools/automation.js'; const server = new McpServer({ name: 'mcp_windows', - version: '2.0.0', + version: '3.0.0', }); // v1.0 — Core @@ -97,6 +102,17 @@ registerVirtualDesktopTools(server); registerFirewallTools(server); registerMaintenanceTools(server); +// v3.0 — WSL & Package Management +registerWslTools(server); +registerWingetTools(server); + +// v3.1 — Storage & System Management +registerStorageTools(server); +registerSystemMgmtTools(server); + +// v3.2 — Automation & Advanced +registerAutomationTools(server); + async function main(): Promise { const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/tools/automation.ts b/src/tools/automation.ts new file mode 100644 index 0000000..43cd57f --- /dev/null +++ b/src/tools/automation.ts @@ -0,0 +1,297 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_shortcut (#78), windows_input (#79), + * windows_font_list (#80), windows_sandbox (#81), windows_screen_capture (#82) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerAutomationTools(server: McpServer): void { + server.tool( + 'windows_shortcut', + 'Create, read, or modify Windows shortcut (.lnk) files.', + { + action: z.enum(['create', 'read']).describe('Action'), + path: z.string().describe('Shortcut .lnk file path'), + target: z.string().optional().describe('Target path (for create)'), + arguments: z.string().optional().describe('Arguments (for create)'), + icon: z.string().optional().describe('Icon path (for create)'), + working_dir: z.string().optional().describe('Working directory (for create)'), + description: z.string().optional().describe('Description (for create)'), + }, + async ({ action, path, target, arguments: args, icon, working_dir, description }) => { + if (action === 'read') { + const ps = ` +$ws = New-Object -ComObject WScript.Shell +$sc = $ws.CreateShortcut('${path.replace(/'/g, "''")}') +[PSCustomObject]@{ + Target = $sc.TargetPath + Arguments = $sc.Arguments + WorkingDir = $sc.WorkingDirectory + Icon = $sc.IconLocation + Description = $sc.Description + Hotkey = $sc.Hotkey + WindowStyle = $sc.WindowStyle +} | ConvertTo-Json -Compress`; + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + const sc = JSON.parse(result.stdout); + return { + content: [{ + type: 'text', + text: `${path}\n Target: ${sc.Target}\n Args: ${sc.Arguments || '(none)'}\n Dir: ${sc.WorkingDir || '(none)'}\n Icon: ${sc.Icon || '(default)'}\n Description: ${sc.Description || '(none)'}`, + }], + }; + } + + if (!target) return { content: [{ type: 'text', text: 'create requires target.' }], isError: true }; + const ps = ` +$ws = New-Object -ComObject WScript.Shell +$sc = $ws.CreateShortcut('${path.replace(/'/g, "''")}') +$sc.TargetPath = '${target.replace(/'/g, "''")}' +${args ? `$sc.Arguments = '${args.replace(/'/g, "''")}'` : ''} +${working_dir ? `$sc.WorkingDirectory = '${working_dir.replace(/'/g, "''")}'` : ''} +${icon ? `$sc.IconLocation = '${icon.replace(/'/g, "''")}'` : ''} +${description ? `$sc.Description = '${description.replace(/'/g, "''")}'` : ''} +$sc.Save() +"Shortcut created: ${path}"`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }], isError: result.exitCode !== 0 }; + }, + ); + + server.tool( + 'windows_input', + 'Simulate keyboard presses, key combos, or type text. Also simulate mouse clicks and movement.', + { + type: z.enum(['key', 'combo', 'text', 'mouse_click', 'mouse_move']).describe('Input type'), + key: z.string().optional().describe('Key name for key/combo (e.g. "Enter", "Ctrl+C", "Alt+F4")'), + text: z.string().optional().describe('Text to type (for text type)'), + x: z.number().optional().describe('Mouse X coordinate'), + y: z.number().optional().describe('Mouse Y coordinate'), + button: z.enum(['left', 'right', 'middle']).default('left').describe('Mouse button'), + delay: z.number().default(50).describe('Delay between actions in ms'), + }, + async ({ type, key, text, x, y, button, delay }) => { + let ps: string; + + switch (type) { + case 'key': + if (!key) return { content: [{ type: 'text', text: 'key requires key name.' }], isError: true }; + ps = ` +Add-Type -AssemblyName System.Windows.Forms +[System.Windows.Forms.SendKeys]::SendWait('{${key.toUpperCase()}}') +"Sent key: ${key}"`; + break; + + case 'combo': + if (!key) return { content: [{ type: 'text', text: 'combo requires key.' }], isError: true }; + // Map Ctrl+C style to SendKeys format: ^c + const mapped = key + .replace(/Ctrl\+/gi, '^') + .replace(/Alt\+/gi, '%') + .replace(/Shift\+/gi, '+') + .replace(/Win\+/gi, '^{ESC}'); // approximate + ps = ` +Add-Type -AssemblyName System.Windows.Forms +[System.Windows.Forms.SendKeys]::SendWait('${mapped}') +"Sent combo: ${key}"`; + break; + + case 'text': + if (!text) return { content: [{ type: 'text', text: 'text requires text.' }], isError: true }; + // Escape SendKeys special chars + const escaped = text.replace(/[+^%~(){}[\]]/g, '{$&}'); + ps = ` +Add-Type -AssemblyName System.Windows.Forms +[System.Windows.Forms.SendKeys]::SendWait('${escaped.replace(/'/g, "''")}') +"Typed ${text.length} characters"`; + break; + + case 'mouse_click': + if (x === undefined || y === undefined) return { content: [{ type: 'text', text: 'mouse_click requires x and y.' }], isError: true }; + const btnFlag = button === 'right' ? '0x0008,0x0010' : button === 'middle' ? '0x0020,0x0040' : '0x0002,0x0004'; + ps = ` +Add-Type @' +using System.Runtime.InteropServices; +public class MouseInput { + [DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y); + [DllImport("user32.dll")] public static extern void mouse_event(uint dwFlags, int dx, int dy, uint dwData, int dwExtraInfo); +} +'@ +[MouseInput]::SetCursorPos(${x}, ${y}) +Start-Sleep -Milliseconds ${delay} +[MouseInput]::mouse_event(${btnFlag.split(',')[0]}, 0, 0, 0, 0) +[MouseInput]::mouse_event(${btnFlag.split(',')[1]}, 0, 0, 0, 0) +"Clicked ${button} at (${x}, ${y})"`; + break; + + case 'mouse_move': + if (x === undefined || y === undefined) return { content: [{ type: 'text', text: 'mouse_move requires x and y.' }], isError: true }; + ps = ` +Add-Type @' +using System.Runtime.InteropServices; +public class MouseMove { [DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y); } +'@ +[MouseMove]::SetCursorPos(${x}, ${y}) +"Moved mouse to (${x}, ${y})"`; + break; + } + + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }], isError: result.exitCode !== 0 }; + }, + ); + + server.tool( + 'windows_font_list', + 'List installed system and user fonts.', + { + filter: z.string().optional().describe('Filter by font name'), + }, + async ({ filter }) => { + const filterClause = filter ? `| Where-Object { $_.Name -like '*${filter.replace(/'/g, "''")}*' }` : ''; + const ps = ` +Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Fonts' | + ForEach-Object { $_.PSObject.Properties } | + Where-Object { $_.Name -notlike 'PS*' } | + ForEach-Object { [PSCustomObject]@{ Name = $_.Name; File = $_.Value } } ${filterClause} | + Sort-Object Name | + ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps, { timeout: 15000 }); + if (result.exitCode !== 0) return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + if (!result.stdout) return { content: [{ type: 'text', text: 'No fonts found.' }] }; + + const fonts = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = fonts.map((f: { Name: string }) => ` ${f.Name}`); + return { content: [{ type: 'text', text: `Installed fonts (${fonts.length}):\n${lines.join('\n')}` }] }; + }, + ); + + server.tool( + 'windows_sandbox', + 'Check Windows Sandbox status, launch with default or custom config.', + { + action: z.enum(['status', 'launch', 'generate_config']).default('status').describe('Action'), + mapped_folder: z.string().optional().describe('Host folder to map into sandbox'), + read_only: z.boolean().default(true).describe('Map folder as read-only'), + networking: z.boolean().default(true).describe('Enable networking in sandbox'), + startup_command: z.string().optional().describe('Command to run on sandbox startup'), + config_path: z.string().optional().describe('Path to save/load .wsb config'), + }, + async ({ action, mapped_folder, read_only, networking, startup_command, config_path }) => { + if (action === 'status') { + const ps = ` +$feature = Get-WindowsOptionalFeature -Online -FeatureName 'Containers-DisposableClientVM' -ErrorAction SilentlyContinue +if ($feature) { + "Windows Sandbox: $($feature.State)" +} else { + "Windows Sandbox feature not found" +}`; + const result = await runPowerShell(ps, { timeout: 15000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + + if (action === 'generate_config' || action === 'launch') { + const wsbLines = ['']; + if (!networking) wsbLines.push(' Disable'); + if (mapped_folder) { + wsbLines.push(' '); + wsbLines.push(' '); + wsbLines.push(` ${mapped_folder}`); + wsbLines.push(` ${read_only ? 'true' : 'false'}`); + wsbLines.push(' '); + wsbLines.push(' '); + } + if (startup_command) { + wsbLines.push(` ${startup_command}`); + } + wsbLines.push(''); + const wsb = wsbLines.join('\n'); + + if (action === 'generate_config') { + if (config_path) { + const ps = `Set-Content -Path '${config_path.replace(/'/g, "''")}' -Value @'\n${wsb}\n'@\n"Config saved: ${config_path}"`; + const result = await runPowerShell(ps, { timeout: 5000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + return { content: [{ type: 'text', text: wsb }] }; + } + + // Launch + const tempWsb = config_path || `$env:TEMP\\mcp_sandbox_${Date.now()}.wsb`; + const ps = ` +Set-Content -Path '${tempWsb}' -Value @' +${wsb} +'@ +Start-Process '${tempWsb}' +"Sandbox launched${config_path ? '' : ' (temp config)'}"`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + + return { content: [{ type: 'text', text: 'Unknown action.' }], isError: true }; + }, + ); + + server.tool( + 'windows_screen_capture', + 'Start or stop screen recording. Uses ffmpeg if available, otherwise reports status only.', + { + action: z.enum(['start', 'stop', 'status']).describe('Action'), + output: z.string().optional().describe('Output file path (for start)'), + fps: z.number().default(15).describe('Framerate'), + }, + async ({ action, output, fps }) => { + if (action === 'status') { + const ps = ` +$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue +$recording = Get-Process ffmpeg -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like '*gdigrab*' } +[PSCustomObject]@{ + FfmpegInstalled = $null -ne $ffmpeg + FfmpegPath = if ($ffmpeg) { $ffmpeg.Source } else { $null } + ActiveRecording = $null -ne $recording + RecordingPID = if ($recording) { $recording.Id } else { $null } +} | ConvertTo-Json -Compress`; + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + const info = JSON.parse(result.stdout); + return { + content: [{ + type: 'text', + text: `ffmpeg: ${info.FfmpegInstalled ? info.FfmpegPath : 'Not installed (install via winget: winget install Gyan.FFmpeg)'}\nRecording: ${info.ActiveRecording ? `Active (PID ${info.RecordingPID})` : 'None'}`, + }], + }; + } + + if (action === 'start') { + const outPath = output || `A:/temp/recording_${Date.now()}.mp4`; + const ps = ` +$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue +if (-not $ffmpeg) { throw "ffmpeg not installed. Install with: winget install Gyan.FFmpeg" } +Start-Process ffmpeg -ArgumentList '-f gdigrab -framerate ${fps} -i desktop -c:v libx264 -preset ultrafast "${outPath}"' -WindowStyle Hidden -PassThru | ForEach-Object { "Recording started (PID $($_.Id)). Output: ${outPath}" }`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }], isError: result.exitCode !== 0 }; + } + + if (action === 'stop') { + const ps = ` +$procs = Get-Process ffmpeg -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like '*gdigrab*' -or $_.CommandLine -eq $null } +if ($procs) { + $procs | ForEach-Object { $_.CloseMainWindow() | Out-Null } + Start-Sleep -Seconds 2 + $procs | Stop-Process -Force -ErrorAction SilentlyContinue + "Recording stopped ($(@($procs).Count) process(es))" +} else { "No active recording found" }`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + + return { content: [{ type: 'text', text: 'Unknown action.' }], isError: true }; + }, + ); +} diff --git a/src/tools/storage.ts b/src/tools/storage.ts new file mode 100644 index 0000000..43f409f --- /dev/null +++ b/src/tools/storage.ts @@ -0,0 +1,141 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_disk_cleanup (#72), windows_symlink (#73) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerStorageTools(server: McpServer): void { + server.tool( + 'windows_disk_cleanup', + 'Analyze disk usage and clean temp files, caches, and logs.', + { + action: z.enum(['analyze', 'clean']).default('analyze').describe('Analyze or clean'), + drive: z.string().default('C:').describe('Drive letter'), + }, + async ({ action, drive }) => { + if (action === 'analyze') { + const ps = ` +$tempUser = [IO.Path]::GetTempPath() +$tempWin = "$env:WINDIR\\Temp" +$downloads = [Environment]::GetFolderPath('UserProfile') + '\\Downloads' + +function Get-FolderSize($path) { + if (Test-Path $path) { + $size = (Get-ChildItem $path -Recurse -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum + [math]::Round($size / 1MB, 1) + } else { 0 } +} + +$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='${drive.replace(/'/g, "''")}'" + +[PSCustomObject]@{ + Drive = '${drive}' + TotalGB = [math]::Round($disk.Size / 1GB, 1) + FreeGB = [math]::Round($disk.FreeSpace / 1GB, 1) + UsedPct = [math]::Round(($disk.Size - $disk.FreeSpace) / $disk.Size * 100, 1) + TempUserMB = Get-FolderSize $tempUser + TempWindowsMB = Get-FolderSize $tempWin + DownloadsMB = Get-FolderSize $downloads + RecycleBinItems = (New-Object -ComObject Shell.Application).Namespace(10).Items().Count +} | ConvertTo-Json -Compress`; + + const result = await runPowerShell(ps, { timeout: 30000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + const d = JSON.parse(result.stdout); + return { + content: [{ + type: 'text', + text: [ + `${d.Drive} — ${d.FreeGB}/${d.TotalGB} GB free (${d.UsedPct}% used)`, + ``, + `Cleanable:`, + ` User temp: ${d.TempUserMB} MB`, + ` Windows temp: ${d.TempWindowsMB} MB`, + ` Downloads: ${d.DownloadsMB} MB`, + ` Recycle Bin: ${d.RecycleBinItems} items`, + ].join('\n'), + }], + }; + } + + const ps = ` +$before = (Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='${drive.replace(/'/g, "''")}'").FreeSpace +Remove-Item "$env:TEMP\\*" -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item "$env:WINDIR\\Temp\\*" -Recurse -Force -ErrorAction SilentlyContinue +$after = (Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='${drive.replace(/'/g, "''")}'").FreeSpace +$freed = [math]::Round(($after - $before) / 1MB, 1) +"Cleaned temp files. Freed: $freed MB"`; + + const result = await runPowerShell(ps, { timeout: 30000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + }, + ); + + server.tool( + 'windows_symlink', + 'Create and manage symbolic links, hard links, and directory junctions.', + { + action: z.enum(['create', 'list', 'resolve', 'remove']).default('list').describe('Action'), + target: z.string().optional().describe('Target path (what the link points to)'), + link: z.string().optional().describe('Link path (the symlink itself)'), + type: z.enum(['symlink', 'junction', 'hardlink']).default('symlink').describe('Link type (for create)'), + path: z.string().optional().describe('Directory to list symlinks in (for list)'), + }, + async ({ action, target, link, type, path }) => { + switch (action) { + case 'create': { + if (!target || !link) { + return { content: [{ type: 'text', text: 'create requires target and link.' }], isError: true }; + } + let cmd: string; + if (type === 'junction') { + cmd = `cmd /c mklink /J "${link}" "${target}"`; + } else if (type === 'hardlink') { + cmd = `cmd /c mklink /H "${link}" "${target}"`; + } else { + cmd = `cmd /c mklink ${target.includes('.') ? '' : '/D'} "${link}" "${target}"`; + } + const ps = `& { ${cmd} } 2>&1`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + case 'list': { + const dir = path || '.'; + const ps = ` +Get-ChildItem -Path '${dir.replace(/'/g, "''")}' -Force -ErrorAction Stop | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint } | ForEach-Object { + [PSCustomObject]@{ + Name = $_.Name + Target = $_.Target + Type = if ($_.PSIsContainer) { 'Directory' } else { 'File' } + LinkType = $_.LinkType + } +} | ConvertTo-Json -Depth 3 -Compress`; + const result = await runPowerShell(ps, { timeout: 10000 }); + if (!result.stdout) return { content: [{ type: 'text', text: 'No symlinks found.' }] }; + const links = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = links.map((l: { Name: string; Target: string; LinkType: string }) => + ` ${(l.LinkType || 'Link').padEnd(12)} ${l.Name.padEnd(30)} -> ${l.Target}`); + return { content: [{ type: 'text', text: `Symlinks in ${dir}:\n${lines.join('\n')}` }] }; + } + case 'resolve': { + if (!link) return { content: [{ type: 'text', text: 'resolve requires link path.' }], isError: true }; + const ps = `(Get-Item '${link.replace(/'/g, "''")}' -Force).Target`; + const result = await runPowerShell(ps, { timeout: 5000 }); + return { content: [{ type: 'text', text: `${link} -> ${result.stdout || '(not a symlink)'}` }] }; + } + case 'remove': { + if (!link) return { content: [{ type: 'text', text: 'remove requires link path.' }], isError: true }; + const ps = `Remove-Item '${link.replace(/'/g, "''")}' -Force -ErrorAction Stop; "Removed: ${link}"`; + const result = await runPowerShell(ps, { timeout: 5000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + } + }, + ); +} diff --git a/src/tools/system_mgmt.ts b/src/tools/system_mgmt.ts new file mode 100644 index 0000000..0232165 --- /dev/null +++ b/src/tools/system_mgmt.ts @@ -0,0 +1,190 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_timezone (#74), windows_features (#75), + * windows_smb_shares (#76), windows_dns_cache (#77) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerSystemMgmtTools(server: McpServer): void { + server.tool( + 'windows_timezone', + 'Get or set system timezone. List available timezones or sync time.', + { + action: z.enum(['get', 'list', 'set', 'sync']).default('get').describe('Action'), + timezone: z.string().optional().describe('Timezone ID (for set, e.g. "Eastern Standard Time")'), + filter: z.string().optional().describe('Filter timezone list'), + }, + async ({ action, timezone, filter }) => { + switch (action) { + case 'get': { + const ps = ` +$tz = Get-TimeZone +[PSCustomObject]@{ + Id = $tz.Id + DisplayName = $tz.DisplayName + UTCOffset = $tz.BaseUtcOffset.ToString() + DST = $tz.SupportsDaylightSavingTime + DSTActive = (Get-Date).IsDaylightSavingTime() + CurrentTime = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') + UTCTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss') +} | ConvertTo-Json -Compress`; + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + const tz = JSON.parse(result.stdout); + return { + content: [{ + type: 'text', + text: `Timezone: ${tz.Id}\n${tz.DisplayName}\nUTC Offset: ${tz.UTCOffset}${tz.DSTActive ? ' (DST active)' : ''}\nLocal: ${tz.CurrentTime}\nUTC: ${tz.UTCTime}`, + }], + }; + } + case 'list': { + const filterClause = filter ? `| Where-Object { $_.Id -like '*${filter.replace(/'/g, "''")}*' -or $_.DisplayName -like '*${filter.replace(/'/g, "''")}*' }` : ''; + const ps = `Get-TimeZone -ListAvailable ${filterClause} | ForEach-Object { "$($_.BaseUtcOffset.ToString().PadRight(9)) $($_.Id)" }`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || 'No timezones found.' }] }; + } + case 'set': { + if (!timezone) return { content: [{ type: 'text', text: 'set requires timezone.' }], isError: true }; + const ps = `Set-TimeZone -Id '${timezone.replace(/'/g, "''")}' -ErrorAction Stop; "Timezone set to: ${timezone}"`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }], isError: result.exitCode !== 0 }; + } + case 'sync': { + const ps = `w32tm /resync /force 2>&1; "Time sync requested"`; + const result = await runPowerShell(ps, { timeout: 15000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + } + }, + ); + + server.tool( + 'windows_features', + 'List, enable, or disable Windows optional features (WSL, Hyper-V, Sandbox, etc.).', + { + action: z.enum(['list', 'enable', 'disable']).default('list').describe('Action'), + name: z.string().optional().describe('Feature name (for enable/disable)'), + filter: z.string().optional().describe('Filter by name (for list)'), + }, + async ({ action, name, filter }) => { + if (action === 'list') { + const filterClause = filter ? `| Where-Object { $_.FeatureName -like '*${filter.replace(/'/g, "''")}*' }` : ''; + const ps = ` +try { + Get-WindowsOptionalFeature -Online -ErrorAction Stop ${filterClause} | Select-Object FeatureName,State | Sort-Object FeatureName | ConvertTo-Json -Depth 3 -Compress +} catch { + if ($_.Exception.Message -match 'elevation') { Write-Output 'NEEDS_ELEVATION' } + else { throw } +}`; + const result = await runPowerShell(ps, { timeout: 30000 }); + if (result.stdout?.trim() === 'NEEDS_ELEVATION') { + return { content: [{ type: 'text', text: 'Windows Optional Features requires elevation (run as Administrator).' }] }; + } + if (result.exitCode !== 0) return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + if (!result.stdout) return { content: [{ type: 'text', text: 'No features found.' }] }; + const features = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = features.map((f: { FeatureName: string; State: number }) => { + const state = f.State === 2 ? '[ON] ' : '[OFF]'; + return `${state} ${f.FeatureName}`; + }); + return { content: [{ type: 'text', text: `${lines.join('\n')}\n\n${features.length} features` }] }; + } + + if (!name) return { content: [{ type: 'text', text: `${action} requires name.` }], isError: true }; + const cmd = action === 'enable' + ? `Enable-WindowsOptionalFeature -Online -FeatureName '${name.replace(/'/g, "''")}' -NoRestart -ErrorAction Stop` + : `Disable-WindowsOptionalFeature -Online -FeatureName '${name.replace(/'/g, "''")}' -NoRestart -ErrorAction Stop`; + const ps = `${cmd} | Select-Object RestartNeeded | ConvertTo-Json -Compress`; + const result = await runPowerShell(ps, { timeout: 60000 }); + if (result.exitCode !== 0) return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + const info = JSON.parse(result.stdout); + return { content: [{ type: 'text', text: `${name}: ${action}d${info.RestartNeeded ? ' (restart required)' : ''}` }] }; + }, + ); + + server.tool( + 'windows_smb_shares', + 'List local shares, mapped drives, or map/unmap network drives.', + { + action: z.enum(['list_shares', 'list_mapped', 'map', 'unmap']).default('list_shares').describe('Action'), + letter: z.string().optional().describe('Drive letter (for map/unmap, e.g. "Z:")'), + unc_path: z.string().optional().describe('UNC path (for map, e.g. "\\\\\\\\server\\\\share")'), + }, + async ({ action, letter, unc_path }) => { + switch (action) { + case 'list_shares': { + const ps = `Get-SmbShare -ErrorAction Stop | Select-Object Name,Path,Description | 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 shares.' }] }; + const shares = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = shares.map((s: { Name: string; Path: string; Description: string }) => + ` ${s.Name.padEnd(20)} ${(s.Path || '').padEnd(30)} ${s.Description || ''}`); + return { content: [{ type: 'text', text: `Local shares:\n${lines.join('\n')}` }] }; + } + case 'list_mapped': { + const ps = `Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { "$($_.Name): $($_.DisplayRoot)" }`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || 'No mapped drives.' }] }; + } + case 'map': { + if (!letter || !unc_path) return { content: [{ type: 'text', text: 'map requires letter and unc_path.' }], isError: true }; + const ps = `net use ${letter} "${unc_path}" /persistent:yes 2>&1`; + const result = await runPowerShell(ps, { timeout: 15000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + case 'unmap': { + if (!letter) return { content: [{ type: 'text', text: 'unmap requires letter.' }], isError: true }; + const ps = `net use ${letter} /delete /yes 2>&1`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + } + }, + ); + + server.tool( + 'windows_dns_cache', + 'View, filter, or clear the DNS resolver cache. Also resolves domain names.', + { + action: z.enum(['list', 'clear', 'resolve']).default('list').describe('Action'), + filter: z.string().optional().describe('Filter by domain (for list)'), + domain: z.string().optional().describe('Domain to resolve (for resolve)'), + limit: z.number().default(30).describe('Max entries (for list)'), + }, + async ({ action, filter, domain, limit }) => { + switch (action) { + case 'list': { + const filterClause = filter ? `| Where-Object { $_.Entry -like '*${filter.replace(/'/g, "''")}*' }` : ''; + const ps = `Get-DnsClientCache ${filterClause} -ErrorAction SilentlyContinue | Select-Object -First ${limit} Entry,RecordName,RecordType,Data,TimeToLive | ConvertTo-Json -Depth 3 -Compress`; + const result = await runPowerShell(ps, { timeout: 10000 }); + if (!result.stdout) return { content: [{ type: 'text', text: 'DNS cache is empty.' }] }; + const entries = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = entries.map((e: { Entry: string; RecordType: number; Data: string; TimeToLive: number }) => + ` ${(e.Entry || '').padEnd(40).slice(0, 40)} ${String(e.RecordType).padEnd(5)} ${String(e.TimeToLive).padStart(6)}s ${e.Data || ''}`); + return { content: [{ type: 'text', text: `DNS Cache (${entries.length} entries):\n${lines.join('\n')}` }] }; + } + case 'clear': { + const ps = `Clear-DnsClientCache; "DNS cache cleared"`; + const result = await runPowerShell(ps, { timeout: 5000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + case 'resolve': { + if (!domain) return { content: [{ type: 'text', text: 'resolve requires domain.' }], isError: true }; + const ps = `Resolve-DnsName '${domain.replace(/'/g, "''")}' -ErrorAction Stop | Select-Object Name,Type,IPAddress,NameHost | 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 records = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = records.map((r: { Name: string; Type: number; IPAddress: string; NameHost: string }) => + ` ${r.Name} ${r.IPAddress || r.NameHost || ''}`); + return { content: [{ type: 'text', text: `${domain}:\n${lines.join('\n')}` }] }; + } + } + }, + ); +} diff --git a/src/tools/winget.ts b/src/tools/winget.ts new file mode 100644 index 0000000..14a70c5 --- /dev/null +++ b/src/tools/winget.ts @@ -0,0 +1,100 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_winget_search (#69), windows_winget_manage (#70), windows_winget_export (#71) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runShell } from '../shell.js'; + +export function registerWingetTools(server: McpServer): void { + server.tool( + 'windows_winget_search', + 'Search for packages in the winget repository.', + { + query: z.string().describe('Search query (name or ID)'), + source: z.enum(['winget', 'msstore', 'all']).default('all').describe('Package source'), + limit: z.number().default(20).describe('Max results'), + }, + async ({ query, source, limit }) => { + const sourceArg = source !== 'all' ? `--source ${source}` : ''; + const cmd = `winget search "${query}" ${sourceArg} --count ${limit} --accept-source-agreements --disable-interactivity`; + const result = await runShell(cmd, { shell: 'cmd', timeout: 30000 }); + const clean = result.stdout.replace(/\0/g, '').trim(); + return { + content: [{ type: 'text', text: clean || 'No packages found.' }], + isError: result.exitCode !== 0 && !clean, + }; + }, + ); + + server.tool( + 'windows_winget_manage', + 'Install, upgrade, or uninstall packages via winget. Also list installed or upgradable packages.', + { + action: z.enum(['install', 'upgrade', 'uninstall', 'list', 'upgradable', 'upgrade_all']).describe('Action'), + id: z.string().optional().describe('Package ID (e.g. "Microsoft.VisualStudioCode")'), + version: z.string().optional().describe('Specific version to install'), + }, + async ({ action, id, version }) => { + let cmd: string; + + switch (action) { + case 'install': + if (!id) return { content: [{ type: 'text', text: 'install requires id.' }], isError: true }; + cmd = `winget install --id "${id}" ${version ? `--version "${version}"` : ''} --accept-package-agreements --accept-source-agreements --disable-interactivity`; + break; + case 'upgrade': + if (!id) return { content: [{ type: 'text', text: 'upgrade requires id.' }], isError: true }; + cmd = `winget upgrade --id "${id}" --accept-package-agreements --accept-source-agreements --disable-interactivity`; + break; + case 'upgrade_all': + cmd = `winget upgrade --all --accept-package-agreements --accept-source-agreements --disable-interactivity`; + break; + case 'uninstall': + if (!id) return { content: [{ type: 'text', text: 'uninstall requires id.' }], isError: true }; + cmd = `winget uninstall --id "${id}" --disable-interactivity`; + break; + case 'list': + cmd = `winget list --accept-source-agreements --disable-interactivity`; + break; + case 'upgradable': + cmd = `winget upgrade --accept-source-agreements --disable-interactivity`; + break; + } + + const timeout = action === 'install' || action === 'upgrade' || action === 'upgrade_all' ? 300000 : 30000; + const result = await runShell(cmd, { shell: 'cmd', timeout }); + const clean = result.stdout.replace(/\0/g, '').trim(); + + return { + content: [{ type: 'text', text: clean || result.stderr || `${action} completed.` }], + isError: result.exitCode !== 0 && !clean, + }; + }, + ); + + server.tool( + 'windows_winget_export', + 'Export installed packages to JSON or import from a JSON file.', + { + action: z.enum(['export', 'import']).describe('Action'), + path: z.string().describe('File path for export/import JSON'), + }, + async ({ action, path }) => { + const cmd = action === 'export' + ? `winget export -o "${path}" --accept-source-agreements --disable-interactivity` + : `winget import -i "${path}" --accept-package-agreements --accept-source-agreements --disable-interactivity`; + + const timeout = action === 'import' ? 600000 : 30000; + const result = await runShell(cmd, { shell: 'cmd', timeout }); + const clean = result.stdout.replace(/\0/g, '').trim(); + + return { + content: [{ type: 'text', text: clean || `${action} completed: ${path}` }], + isError: result.exitCode !== 0 && !clean, + }; + }, + ); +} diff --git a/src/tools/wsl.ts b/src/tools/wsl.ts new file mode 100644 index 0000000..186340f --- /dev/null +++ b/src/tools/wsl.ts @@ -0,0 +1,112 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_wsl_list (#66), windows_wsl_manage (#67), windows_wsl_exec (#68) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runShell, runPowerShell } from '../shell.js'; + +export function registerWslTools(server: McpServer): void { + server.tool( + 'windows_wsl_list', + 'List installed WSL distributions with status, version, and default flag.', + {}, + async () => { + // wsl.exe outputs UTF-16; use PowerShell to decode properly + const ps = `$out = wsl --list --verbose 2>&1; if ($LASTEXITCODE -ne 0) { "WSL_ERROR:$out" } else { $out }`; + const result = await runPowerShell(ps, { timeout: 15000 }); + const clean = result.stdout.replace(/\0/g, '').trim(); + if (clean.startsWith('WSL_ERROR:') || result.exitCode !== 0) { + const msg = clean.replace('WSL_ERROR:', '').trim(); + return { content: [{ type: 'text', text: msg || 'WSL is not installed or not available. Install with: wsl --install' }] }; + } + return { content: [{ type: 'text', text: clean || 'No WSL distributions installed.' }] }; + }, + ); + + server.tool( + 'windows_wsl_manage', + 'Manage WSL: start, stop, shutdown, set default, export, import, or remove distros.', + { + action: z.enum(['shutdown', 'terminate', 'set_default', 'set_version', 'export', 'import', 'unregister']).describe('Action'), + distro: z.string().optional().describe('Distribution name'), + version: z.number().optional().describe('WSL version 1 or 2 (for set_version)'), + path: z.string().optional().describe('File path (for export/import)'), + }, + async ({ action, distro, version, path }) => { + let cmd: string; + + switch (action) { + case 'shutdown': + cmd = 'wsl --shutdown'; + break; + case 'terminate': + if (!distro) return { content: [{ type: 'text', text: 'terminate requires distro.' }], isError: true }; + cmd = `wsl --terminate ${distro}`; + break; + case 'set_default': + if (!distro) return { content: [{ type: 'text', text: 'set_default requires distro.' }], isError: true }; + cmd = `wsl --set-default ${distro}`; + break; + case 'set_version': + if (!distro || !version) return { content: [{ type: 'text', text: 'set_version requires distro and version.' }], isError: true }; + cmd = `wsl --set-version ${distro} ${version}`; + break; + case 'export': + if (!distro || !path) return { content: [{ type: 'text', text: 'export requires distro and path.' }], isError: true }; + cmd = `wsl --export ${distro} "${path}"`; + break; + case 'import': + if (!distro || !path) return { content: [{ type: 'text', text: 'import requires distro and path.' }], isError: true }; + cmd = `wsl --import ${distro} "C:\\WSL\\${distro}" "${path}"`; + break; + case 'unregister': + if (!distro) return { content: [{ type: 'text', text: 'unregister requires distro.' }], isError: true }; + cmd = `wsl --unregister ${distro}`; + break; + } + + const result = await runShell(cmd, { shell: 'cmd', timeout: 60000 }); + const clean = (result.stdout + '\n' + result.stderr).replace(/\0/g, '').trim(); + return { + content: [{ type: 'text', text: clean || `${action} completed.` }], + isError: result.exitCode !== 0, + }; + }, + ); + + server.tool( + 'windows_wsl_exec', + 'Execute a command inside a WSL distribution. Returns stdout, stderr, exit code.', + { + command: z.string().describe('Command to execute'), + distro: z.string().optional().describe('Distribution name (default distro if omitted)'), + user: z.string().optional().describe('Run as user'), + cwd: z.string().optional().describe('Working directory inside WSL'), + timeout: z.number().default(30000).describe('Timeout in ms'), + }, + async ({ command, distro, user, cwd, timeout }) => { + const parts = ['wsl']; + if (distro) parts.push('-d', distro); + if (user) parts.push('-u', user); + if (cwd) parts.push('--cd', cwd); + parts.push('--', 'bash', '-c', `"${command.replace(/"/g, '\\"')}"`); + + const result = await runShell(parts.join(' '), { shell: 'cmd', timeout }); + const clean = result.stdout.replace(/\0/g, '').trim(); + const cleanErr = result.stderr.replace(/\0/g, '').trim(); + + const output: string[] = []; + if (clean) output.push(clean); + if (cleanErr) output.push(`[stderr]\n${cleanErr}`); + output.push(`[exit code: ${result.exitCode}]`); + + return { + content: [{ type: 'text', text: output.join('\n\n') }], + isError: result.exitCode !== 0, + }; + }, + ); +} -- 2.52.0