feat: v3.0.0 -- WSL, winget, storage, automation (80 tools) #83
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 }] };
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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')}` }] };
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user