feat: v3.0.0 -- WSL, winget, storage, automation (80 tools) #83

Merged
jmiller merged 2 commits from dev into main 2026-05-26 04:50:30 +00:00
8 changed files with 917 additions and 2 deletions
+59
View File
@@ -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
+1 -1
View File
@@ -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",
+17 -1
View File
@@ -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<void> {
const transport = new StdioServerTransport();
await server.connect(transport);
+297
View File
@@ -0,0 +1,297 @@
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* 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 = ['<Configuration>'];
if (!networking) wsbLines.push(' <Networking>Disable</Networking>');
if (mapped_folder) {
wsbLines.push(' <MappedFolders>');
wsbLines.push(' <MappedFolder>');
wsbLines.push(` <HostFolder>${mapped_folder}</HostFolder>`);
wsbLines.push(` <ReadOnly>${read_only ? 'true' : 'false'}</ReadOnly>`);
wsbLines.push(' </MappedFolder>');
wsbLines.push(' </MappedFolders>');
}
if (startup_command) {
wsbLines.push(` <LogonCommand><Command>${startup_command}</Command></LogonCommand>`);
}
wsbLines.push('</Configuration>');
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 };
},
);
}
+141
View File
@@ -0,0 +1,141 @@
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* 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 }] };
}
}
},
);
}
+190
View File
@@ -0,0 +1,190 @@
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* 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')}` }] };
}
}
},
);
}
+100
View File
@@ -0,0 +1,100 @@
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* 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,
};
},
);
}
+112
View File
@@ -0,0 +1,112 @@
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
* 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,
};
},
);
}