diff --git a/src/index.ts b/src/index.ts index 9970c1f..083043f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,10 +32,19 @@ import { registerAppsTools } from './tools/apps.js'; import { registerDialogTools } from './tools/dialog.js'; import { registerNetstatTools } from './tools/netstat.js'; import { registerRecycleBinTools } from './tools/recycle_bin.js'; +import { registerBluetoothTools } from './tools/bluetooth.js'; +import { registerWifiTools } from './tools/wifi.js'; +import { registerUsbTools } from './tools/usb.js'; +import { registerPrinterTools } from './tools/printer.js'; +import { registerHostsTools } from './tools/hosts.js'; +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'; const server = new McpServer({ name: 'mcp_windows', - version: '1.0.0', + version: '2.0.0', }); // v1.0 — Core @@ -73,6 +82,21 @@ registerDialogTools(server); registerNetstatTools(server); registerRecycleBinTools(server); +// v2.0 — Connectivity & Hardware +registerBluetoothTools(server); +registerWifiTools(server); +registerUsbTools(server); +registerPrinterTools(server); +registerHostsTools(server); + +// v2.1 — Appearance & Desktop +registerThemeTools(server); +registerVirtualDesktopTools(server); + +// v2.2 — Security & Maintenance +registerFirewallTools(server); +registerMaintenanceTools(server); + async function main(): Promise { const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/tools/bluetooth.ts b/src/tools/bluetooth.ts new file mode 100644 index 0000000..b47d800 --- /dev/null +++ b/src/tools/bluetooth.ts @@ -0,0 +1,114 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_bluetooth_get (#45), windows_bluetooth_control (#46) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerBluetoothTools(server: McpServer): void { + server.tool( + 'windows_bluetooth_get', + 'Get Bluetooth adapter status and list paired/connected devices.', + {}, + async () => { + const ps = ` +$adapter = Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | Where-Object { $_.FriendlyName -match 'Bluetooth' -and $_.Class -eq 'Bluetooth' } | Select-Object -First 1 +$enabled = if ($adapter) { $adapter.Status -eq 'OK' } else { $false } + +$devices = Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | + Where-Object { $_.FriendlyName -and $_.Class -eq 'Bluetooth' -and $_.FriendlyName -notmatch 'Bluetooth|Radio|Enumerator' } | + ForEach-Object { + [PSCustomObject]@{ + Name = $_.FriendlyName + Status = $_.Status + InstanceId = $_.InstanceId + Connected = $_.Status -eq 'OK' + } + } + +[PSCustomObject]@{ + AdapterPresent = $null -ne $adapter + AdapterName = if ($adapter) { $adapter.FriendlyName } else { 'None' } + Enabled = $enabled + Devices = $devices +} | ConvertTo-Json -Depth 4 -Compress`; + + const result = await runPowerShell(ps, { timeout: 15000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + const info = JSON.parse(result.stdout); + const lines: string[] = [ + `Bluetooth: ${info.AdapterPresent ? (info.Enabled ? 'ON' : 'OFF') : 'Not available'}`, + `Adapter: ${info.AdapterName}`, + ]; + + const devices = Array.isArray(info.Devices) ? info.Devices : info.Devices ? [info.Devices] : []; + if (devices.length > 0) { + lines.push('', 'Paired Devices:'); + for (const d of devices) { + const icon = d.Connected ? '[CON]' : '[ ]'; + lines.push(` ${icon} ${d.Name}`); + } + } else { + lines.push('', 'No paired devices.'); + } + + return { content: [{ type: 'text', text: lines.join('\n') }] }; + }, + ); + + server.tool( + 'windows_bluetooth_control', + 'Enable/disable Bluetooth adapter or disconnect a device.', + { + action: z.enum(['enable', 'disable', 'disconnect']).describe('Action'), + device: z.string().optional().describe('Device name (for disconnect)'), + }, + async ({ action, device }) => { + let ps: string; + + switch (action) { + case 'enable': + ps = ` +$adapter = Get-PnpDevice -Class Bluetooth -ErrorAction Stop | Where-Object { $_.FriendlyName -match 'Bluetooth' -and $_.Class -eq 'Bluetooth' } | Select-Object -First 1 +if ($adapter) { + Enable-PnpDevice -InstanceId $adapter.InstanceId -Confirm:$false -ErrorAction Stop + "Bluetooth enabled" +} else { "No Bluetooth adapter found" }`; + break; + case 'disable': + ps = ` +$adapter = Get-PnpDevice -Class Bluetooth -ErrorAction Stop | Where-Object { $_.FriendlyName -match 'Bluetooth' -and $_.Class -eq 'Bluetooth' } | Select-Object -First 1 +if ($adapter) { + Disable-PnpDevice -InstanceId $adapter.InstanceId -Confirm:$false -ErrorAction Stop + "Bluetooth disabled" +} else { "No Bluetooth adapter found" }`; + break; + case 'disconnect': + if (!device) { + return { content: [{ type: 'text', text: 'Disconnect requires device name.' }], isError: true }; + } + ps = ` +$dev = Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | Where-Object { $_.FriendlyName -like '*${device.replace(/'/g, "''")}*' } | Select-Object -First 1 +if ($dev) { + Disable-PnpDevice -InstanceId $dev.InstanceId -Confirm:$false -ErrorAction Stop + Start-Sleep -Seconds 1 + Enable-PnpDevice -InstanceId $dev.InstanceId -Confirm:$false -ErrorAction Stop + "Disconnected and re-enabled: $($dev.FriendlyName)" +} else { "Device not found: ${device}" }`; + break; + } + + const result = await runPowerShell(ps, { timeout: 15000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +} diff --git a/src/tools/firewall.ts b/src/tools/firewall.ts new file mode 100644 index 0000000..5508866 --- /dev/null +++ b/src/tools/firewall.ts @@ -0,0 +1,125 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_firewall_get (#57), windows_firewall_manage (#58) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerFirewallTools(server: McpServer): void { + server.tool( + 'windows_firewall_get', + 'Get Windows Firewall status and list rules.', + { + filter: z.string().optional().describe('Filter rules by name (substring)'), + direction: z.enum(['inbound', 'outbound', 'all']).default('all').describe('Rule direction'), + enabled_only: z.boolean().default(true).describe('Only show enabled rules'), + limit: z.number().default(30).describe('Max rules to return'), + }, + async ({ filter, direction, enabled_only, limit }) => { + const dirFilter = direction === 'inbound' ? "| Where-Object { \\$_.Direction -eq 'Inbound' }" + : direction === 'outbound' ? "| Where-Object { \\$_.Direction -eq 'Outbound' }" : ''; + const nameFilter = filter ? `| Where-Object { \\$_.DisplayName -like '*${filter.replace(/'/g, "''")}*' }` : ''; + const enabledFilter = enabled_only ? "| Where-Object { \\$_.Enabled -eq 'True' }" : ''; + + const ps = ` +$profiles = Get-NetFirewallProfile -ErrorAction SilentlyContinue | ForEach-Object { + [PSCustomObject]@{ Name = $_.Name; Enabled = $_.Enabled; DefaultInbound = $_.DefaultInboundAction; DefaultOutbound = $_.DefaultOutboundAction } +} + +$rules = Get-NetFirewallRule ${dirFilter} ${nameFilter} ${enabledFilter} -ErrorAction SilentlyContinue | + Select-Object -First ${limit} | ForEach-Object { + $port = ($_ | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue) + [PSCustomObject]@{ + Name = $_.DisplayName + Direction = $_.Direction.ToString() + Action = $_.Action.ToString() + Enabled = $_.Enabled.ToString() + Protocol = if ($port) { $port.Protocol } else { 'Any' } + Port = if ($port.LocalPort) { $port.LocalPort -join ',' } else { 'Any' } + Program = ($_ | Get-NetFirewallApplicationFilter -ErrorAction SilentlyContinue).Program + } + } + +[PSCustomObject]@{ + Profiles = $profiles + Rules = $rules +} | ConvertTo-Json -Depth 4 -Compress`; + + const result = await runPowerShell(ps, { timeout: 30000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + const data = JSON.parse(result.stdout); + const lines: string[] = ['Firewall Profiles:']; + const profiles = Array.isArray(data.Profiles) ? data.Profiles : [data.Profiles]; + for (const p of profiles.filter(Boolean)) { + lines.push(` ${p.Name}: ${p.Enabled ? 'ON' : 'OFF'} (In: ${p.DefaultInbound}, Out: ${p.DefaultOutbound})`); + } + + const rules = Array.isArray(data.Rules) ? data.Rules : data.Rules ? [data.Rules] : []; + if (rules.length > 0) { + lines.push('', `Rules (${rules.length}):`); + for (const r of rules) { + const dir = r.Direction === 'Inbound' ? 'IN ' : 'OUT'; + const act = r.Action === 'Allow' ? 'ALLOW' : 'BLOCK'; + lines.push(` [${dir}] [${act}] ${(r.Name || '').padEnd(35).slice(0, 35)} ${r.Protocol}/${r.Port}`); + } + } + + return { content: [{ type: 'text', text: lines.join('\n') }] }; + }, + ); + + server.tool( + 'windows_firewall_manage', + 'Create, enable, disable, or delete a firewall rule.', + { + action: z.enum(['create', 'enable', 'disable', 'delete']).describe('Action'), + name: z.string().describe('Rule name'), + direction: z.enum(['inbound', 'outbound']).optional().describe('Direction (for create)'), + rule_action: z.enum(['allow', 'block']).optional().describe('Allow or block (for create)'), + port: z.string().optional().describe('Port number or range (for create)'), + protocol: z.enum(['TCP', 'UDP', 'Any']).optional().describe('Protocol (for create)'), + program: z.string().optional().describe('Program path (for create)'), + }, + async ({ action, name, direction, rule_action, port, protocol, program }) => { + let ps: string; + + switch (action) { + case 'create': { + if (!direction || !rule_action) { + return { content: [{ type: 'text', text: 'Create requires direction and rule_action.' }], isError: true }; + } + const dir = direction === 'inbound' ? 'Inbound' : 'Outbound'; + const act = rule_action === 'allow' ? 'Allow' : 'Block'; + const parts = [`New-NetFirewallRule -DisplayName '${name.replace(/'/g, "''")}' -Direction ${dir} -Action ${act}`]; + if (port) parts.push(`-LocalPort ${port}`); + if (protocol && protocol !== 'Any') parts.push(`-Protocol ${protocol}`); + if (program) parts.push(`-Program '${program.replace(/'/g, "''")}'`); + parts.push('-ErrorAction Stop'); + ps = `${parts.join(' ')} | Select-Object DisplayName,Direction,Action,Enabled | ConvertTo-Json -Compress`; + break; + } + case 'enable': + ps = `Enable-NetFirewallRule -DisplayName '${name.replace(/'/g, "''")}' -ErrorAction Stop; "Enabled: ${name}"`; + break; + case 'disable': + ps = `Disable-NetFirewallRule -DisplayName '${name.replace(/'/g, "''")}' -ErrorAction Stop; "Disabled: ${name}"`; + break; + case 'delete': + ps = `Remove-NetFirewallRule -DisplayName '${name.replace(/'/g, "''")}' -ErrorAction Stop; "Deleted: ${name}"`; + break; + } + + const result = await runPowerShell(ps, { timeout: 15000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +} diff --git a/src/tools/hosts.ts b/src/tools/hosts.ts new file mode 100644 index 0000000..c439c8e --- /dev/null +++ b/src/tools/hosts.ts @@ -0,0 +1,135 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_hosts_file (#51) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { readFile, writeFile, copyFile } from 'node:fs/promises'; + +const HOSTS_PATH = 'C:\\Windows\\System32\\drivers\\etc\\hosts'; + +export function registerHostsTools(server: McpServer): void { + server.tool( + 'windows_hosts_file', + 'Read and manage the Windows hosts file. List, add, remove, or toggle entries.', + { + action: z.enum(['list', 'add', 'remove', 'enable', 'disable']).default('list').describe('Action'), + ip: z.string().optional().describe('IP address (for add)'), + hostname: z.string().optional().describe('Hostname (for add/remove/enable/disable)'), + comment: z.string().optional().describe('Comment (for add)'), + }, + async ({ action, ip, hostname, comment }) => { + try { + const content = await readFile(HOSTS_PATH, 'utf-8'); + const lines = content.split(/\r?\n/); + + if (action === 'list') { + const entries: Array<{ line: number; enabled: boolean; ip: string; host: string; comment: string }> = []; + lines.forEach((line, i) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') && !trimmed.match(/^#\s*\d/)) { + // Check if it's a commented-out entry + const commented = trimmed.replace(/^#\s*/, ''); + const parts = commented.split(/\s+/); + if (parts.length >= 2 && parts[0].match(/^\d+\.\d+\.\d+\.\d+$|^[a-f0-9:]+$/i)) { + entries.push({ + line: i + 1, + enabled: false, + ip: parts[0], + host: parts[1], + comment: parts.slice(2).join(' ').replace(/^#\s*/, ''), + }); + } + return; + } + const parts = trimmed.split(/\s+/); + if (parts.length >= 2 && parts[0].match(/^\d+\.\d+\.\d+\.\d+$|^[a-f0-9:]+$/i)) { + entries.push({ + line: i + 1, + enabled: true, + ip: parts[0], + host: parts[1], + comment: parts.slice(2).join(' ').replace(/^#\s*/, ''), + }); + } + }); + + const output = entries.map(e => { + const status = e.enabled ? '[ON] ' : '[OFF]'; + return `${status} ${e.ip.padEnd(18)} ${e.host.padEnd(35)} ${e.comment}`; + }); + + const header = `State ${'IP'.padEnd(18)} ${'Hostname'.padEnd(35)} Comment`; + return { + content: [{ type: 'text', text: `${HOSTS_PATH}\n${header}\n${'─'.repeat(80)}\n${output.join('\n')}\n\n${entries.length} entries` }], + }; + } + + // Backup before modification + await copyFile(HOSTS_PATH, HOSTS_PATH + '.bak'); + + if (action === 'add') { + if (!ip || !hostname) { + return { content: [{ type: 'text', text: 'Add requires ip and hostname.' }], isError: true }; + } + const entry = comment ? `${ip}\t${hostname}\t# ${comment}` : `${ip}\t${hostname}`; + const newContent = content.trimEnd() + '\n' + entry + '\n'; + await writeFile(HOSTS_PATH, newContent, 'utf-8'); + return { content: [{ type: 'text', text: `Added: ${ip} ${hostname}` }] }; + } + + if (action === 'remove') { + if (!hostname) { + return { content: [{ type: 'text', text: 'Remove requires hostname.' }], isError: true }; + } + const filtered = lines.filter(line => { + const parts = line.trim().replace(/^#\s*/, '').split(/\s+/); + return !(parts.length >= 2 && parts[1] === hostname); + }); + await writeFile(HOSTS_PATH, filtered.join('\n'), 'utf-8'); + return { content: [{ type: 'text', text: `Removed entries for: ${hostname}` }] }; + } + + if (action === 'disable') { + if (!hostname) { + return { content: [{ type: 'text', text: 'Disable requires hostname.' }], isError: true }; + } + const updated = lines.map(line => { + const parts = line.trim().split(/\s+/); + if (parts.length >= 2 && parts[1] === hostname && !line.trim().startsWith('#')) { + return '# ' + line; + } + return line; + }); + await writeFile(HOSTS_PATH, updated.join('\n'), 'utf-8'); + return { content: [{ type: 'text', text: `Disabled: ${hostname}` }] }; + } + + if (action === 'enable') { + if (!hostname) { + return { content: [{ type: 'text', text: 'Enable requires hostname.' }], isError: true }; + } + const updated = lines.map(line => { + const trimmed = line.trim(); + if (trimmed.startsWith('#')) { + const uncommented = trimmed.replace(/^#\s*/, ''); + const parts = uncommented.split(/\s+/); + if (parts.length >= 2 && parts[1] === hostname) { + return uncommented; + } + } + return line; + }); + await writeFile(HOSTS_PATH, updated.join('\n'), 'utf-8'); + return { content: [{ type: 'text', text: `Enabled: ${hostname}` }] }; + } + + return { content: [{ type: 'text', text: 'Unknown action.' }], isError: true }; + } catch (err) { + return { content: [{ type: 'text', text: `Error: ${err}. Hosts file modification may require elevation.` }], isError: true }; + } + }, + ); +} diff --git a/src/tools/maintenance.ts b/src/tools/maintenance.ts new file mode 100644 index 0000000..a42648b --- /dev/null +++ b/src/tools/maintenance.ts @@ -0,0 +1,299 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_updates (#59), windows_event_log (#60), + * windows_restore_point (#61), windows_certificate_list (#62), + * windows_performance_monitor (#63) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerMaintenanceTools(server: McpServer): void { + + server.tool( + 'windows_updates', + 'Check Windows Update status, pending updates, and recent history.', + { + action: z.enum(['status', 'history']).default('status').describe('Check status or view history'), + limit: z.number().default(20).describe('Max history entries'), + }, + async ({ action, limit }) => { + if (action === 'history') { + const ps = ` +$session = New-Object -ComObject Microsoft.Update.Session +$searcher = $session.CreateUpdateSearcher() +$count = $searcher.GetTotalHistoryCount() +$history = $searcher.QueryHistory(0, [math]::Min($count, ${limit})) +$history | ForEach-Object { + [PSCustomObject]@{ + Date = $_.Date.ToString('yyyy-MM-dd HH:mm') + Title = $_.Title + Result = switch ($_.ResultCode) { 0 {'Not Started'} 1 {'In Progress'} 2 {'Succeeded'} 3 {'Succeeded with Errors'} 4 {'Failed'} 5 {'Aborted'} default {'Unknown'} } + } +} | ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps, { timeout: 30000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + if (!result.stdout) { + return { content: [{ type: 'text', text: 'No update history.' }] }; + } + + const entries = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = entries.map((e: { Date: string; Title: string; Result: string }) => + `${e.Result.padEnd(10)} ${e.Date} ${(e.Title || '').slice(0, 70)}`, + ); + return { content: [{ type: 'text', text: `Recent updates:\n${lines.join('\n')}` }] }; + } + + // Status + const ps = ` +$reboot = Test-Path 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\WindowsUpdate\\Auto Update\\RebootRequired' +$lastCheck = (Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\WindowsUpdate\\Auto Update\\Results\\Detect' -Name LastSuccessTime -ErrorAction SilentlyContinue).LastSuccessTime +[PSCustomObject]@{ + RestartPending = $reboot + LastCheckTime = $lastCheck +} | ConvertTo-Json -Compress`; + + const result = await runPowerShell(ps, { timeout: 15000 }); + 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: `Windows Update:\n Restart pending: ${info.RestartPending}\n Last check: ${info.LastCheckTime || 'Unknown'}`, + }], + }; + }, + ); + + server.tool( + 'windows_event_log', + 'Read Windows Event Log entries. Filter by log, level, source, or time.', + { + log: z.string().default('System').describe('Log name (System, Application, Security, etc.)'), + level: z.enum(['Critical', 'Error', 'Warning', 'Information', 'All']).default('All').describe('Event level'), + source: z.string().optional().describe('Event source filter'), + limit: z.number().default(20).describe('Max entries'), + hours: z.number().optional().describe('Only events from last N hours'), + }, + async ({ log, level, source, limit, hours }) => { + const levelMap: Record = { + Critical: '1', Error: '2', Warning: '3', Information: '4', + }; + const levelFilter = level !== 'All' ? `-Level ${levelMap[level]}` : ''; + const sourceFilter = source ? `-ProviderName '${source.replace(/'/g, "''")}'` : ''; + const timeFilter = hours ? `-After (Get-Date).AddHours(-${hours})` : ''; + + const ps = ` +Get-WinEvent -LogName '${log.replace(/'/g, "''")}' -MaxEvents ${limit} ${levelFilter ? `| Where-Object { $_.Level -eq ${levelMap[level]} }` : ''} -ErrorAction SilentlyContinue | + ${source ? `Where-Object { $_.ProviderName -like '*${source.replace(/'/g, "''")}*' } |` : ''} + ${hours ? `Where-Object { $_.TimeCreated -gt (Get-Date).AddHours(-${hours}) } |` : ''} + Select-Object -First ${limit} | + ForEach-Object { + [PSCustomObject]@{ + Time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + Level = $_.LevelDisplayName + Source = $_.ProviderName + EventId = $_.Id + Message = ($_.Message -split [char]10)[0].Substring(0, [math]::Min(($_.Message -split [char]10)[0].Length, 100)) + } + } | ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps, { timeout: 20000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + if (!result.stdout) { + return { content: [{ type: 'text', text: 'No events found.' }] }; + } + + const events = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = events.map((e: { Time: string; Level: string; Source: string; EventId: number; Message: string }) => + `${(e.Level || '').padEnd(12)} ${e.Time} ${String(e.EventId).padStart(5)} ${(e.Source || '').padEnd(25).slice(0, 25)} ${(e.Message || '').slice(0, 50)}`, + ); + + const header = `${'Level'.padEnd(12)} ${'Time'.padEnd(19)} ${'ID'.padStart(5)} ${'Source'.padEnd(25)} Message`; + return { content: [{ type: 'text', text: `${log} log:\n${header}\n${'─'.repeat(120)}\n${lines.join('\n')}` }] }; + }, + ); + + server.tool( + 'windows_restore_point', + 'List or create System Restore points.', + { + action: z.enum(['list', 'create']).default('list').describe('Action'), + description: z.string().optional().describe('Description for new restore point'), + }, + async ({ action, description }) => { + if (action === 'create') { + const desc = description || 'mcp_windows restore point'; + const ps = `Checkpoint-Computer -Description '${desc.replace(/'/g, "''")}' -RestorePointType MODIFY_SETTINGS -ErrorAction Stop; "Restore point created: ${desc}"`; + const result = await runPowerShell(ps, { timeout: 60000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }], isError: result.exitCode !== 0 }; + } + + const ps = ` +try { + $points = Get-ComputerRestorePoint -ErrorAction Stop + if (-not $points) { Write-Output '[]'; return } + $points | ForEach-Object { + [PSCustomObject]@{ + SequenceNumber = $_.SequenceNumber + Description = $_.Description + Type = switch ($_.RestorePointType) { 0 {'Application Install'} 1 {'Application Uninstall'} 10 {'Device Install'} 12 {'Modify Settings'} 13 {'Cancel'} default {'Other'} } + Date = $_.ConvertToDateTime($_.CreationTime).ToString('yyyy-MM-dd HH:mm') + } + } | ConvertTo-Json -Depth 3 -Compress +} catch { + Write-Output "RESTORE_ERROR:$($_.Exception.Message)" +}`; + + const result = await runPowerShell(ps, { timeout: 15000 }); + if (result.stdout?.startsWith('RESTORE_ERROR:')) { + const msg = result.stdout.replace('RESTORE_ERROR:', ''); + return { content: [{ type: 'text', text: `System Restore: ${msg || 'Requires elevation or System Restore is disabled.'}` }] }; + } + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr || 'Requires elevation.'}` }], isError: true }; + } + if (!result.stdout || result.stdout === '[]') { + return { content: [{ type: 'text', text: 'No restore points found (System Restore may be disabled or requires elevation).' }] }; + } + + const points = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = points.map((p: { SequenceNumber: number; Description: string; Type: string; Date: string }) => + ` #${p.SequenceNumber} ${p.Date} ${(p.Type || '').padEnd(20)} ${p.Description}`, + ); + return { content: [{ type: 'text', text: `System Restore points:\n${lines.join('\n')}` }] }; + }, + ); + + server.tool( + 'windows_certificate_list', + 'List certificates in the Windows certificate store.', + { + store: z.string().default('Cert:\\CurrentUser\\My').describe('Certificate store path'), + expiring_days: z.number().optional().describe('Only show certs expiring within N days'), + filter: z.string().optional().describe('Filter by subject (substring)'), + }, + async ({ store, expiring_days, filter }) => { + const expiryFilter = expiring_days + ? `| Where-Object { $_.NotAfter -lt (Get-Date).AddDays(${expiring_days}) -and $_.NotAfter -gt (Get-Date) }` + : ''; + const nameFilter = filter + ? `| Where-Object { $_.Subject -like '*${filter.replace(/'/g, "''")}*' }` + : ''; + + // Map store path to .NET enums + // Cert:\CurrentUser\My -> CurrentUser, My + const storeMatch = store.match(/Cert:\\\\?(CurrentUser|LocalMachine)\\\\?(\w+)/i); + const storeLocation = storeMatch ? storeMatch[1] : 'CurrentUser'; + const storeName = storeMatch ? storeMatch[2] : 'My'; + + const ps = ` +$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('${storeName}', '${storeLocation}') +$store.Open('ReadOnly') +$certs = $store.Certificates +${expiring_days ? `$certs = $certs | Where-Object { $_.NotAfter -lt (Get-Date).AddDays(${expiring_days}) -and $_.NotAfter -gt (Get-Date) }` : ''} +${filter ? `$certs = $certs | Where-Object { $_.Subject -like '*${filter.replace(/'/g, "''")}*' }` : ''} +$certs | ForEach-Object { + [PSCustomObject]@{ + Subject = $_.Subject + Issuer = $_.Issuer + Thumbprint = $_.Thumbprint + Expires = $_.NotAfter.ToString('yyyy-MM-dd') + KeyUsage = ($_.Extensions | Where-Object { $_ -is [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension] } | ForEach-Object { ($_.EnhancedKeyUsages | ForEach-Object { $_.FriendlyName }) -join ', ' }) + } +} | ConvertTo-Json -Depth 3 -Compress +$store.Close()`; + + 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 certificates found.' }] }; + } + + const certs = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = certs.map((c: { Subject: string; Expires: string; Thumbprint: string; KeyUsage: string }) => + ` ${c.Expires} ${(c.Thumbprint || '').slice(0, 16)}... ${(c.Subject || '').slice(0, 60)}`, + ); + + const header = ` ${'Expires'.padEnd(10)} ${'Thumbprint'.padEnd(19)} Subject`; + return { content: [{ type: 'text', text: `${store}\n${header}\n${'─'.repeat(90)}\n${lines.join('\n')}\n\n${certs.length} certificates` }] }; + }, + ); + + server.tool( + 'windows_performance_monitor', + 'Get real-time system performance: CPU per core, RAM, disk I/O, network throughput, top processes.', + {}, + async () => { + const ps = ` +$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1 +$os = Get-CimInstance Win32_OperatingSystem +$cs = Get-CimInstance Win32_ComputerSystem +$perfDisk = Get-CimInstance Win32_PerfFormattedData_PerfDisk_LogicalDisk -Filter "Name='_Total'" -ErrorAction SilentlyContinue +$perfNet = Get-CimInstance Win32_PerfFormattedData_Tcpip_NetworkInterface -ErrorAction SilentlyContinue | Select-Object -First 1 + +$topCPU = Get-Process | Sort-Object CPU -Descending | Select-Object -First 5 | ForEach-Object { + [PSCustomObject]@{ Name = $_.ProcessName; PID = $_.Id; CPU = [math]::Round($_.CPU, 1); MemMB = [math]::Round($_.WorkingSet64 / 1MB, 1) } +} +$topMem = Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 5 | ForEach-Object { + [PSCustomObject]@{ Name = $_.ProcessName; PID = $_.Id; MemMB = [math]::Round($_.WorkingSet64 / 1MB, 1) } +} + +[PSCustomObject]@{ + CPUUsage = "$([math]::Round($cpu.LoadPercentage, 0))%" + CPUCores = $cpu.NumberOfLogicalProcessors + RAMTotalGB = [math]::Round($cs.TotalPhysicalMemory / 1GB, 1) + RAMUsedGB = [math]::Round(($cs.TotalPhysicalMemory - $os.FreePhysicalMemory * 1KB) / 1GB, 1) + RAMUsedPct = [math]::Round(($cs.TotalPhysicalMemory - $os.FreePhysicalMemory * 1KB) / $cs.TotalPhysicalMemory * 100, 1) + DiskReadBps = if ($perfDisk) { "$([math]::Round($perfDisk.DiskReadBytesPersec / 1MB, 2)) MB/s" } else { 'N/A' } + DiskWriteBps = if ($perfDisk) { "$([math]::Round($perfDisk.DiskWriteBytesPersec / 1MB, 2)) MB/s" } else { 'N/A' } + NetSentBps = if ($perfNet) { "$([math]::Round($perfNet.BytesSentPersec / 1MB, 2)) MB/s" } else { 'N/A' } + NetRecvBps = if ($perfNet) { "$([math]::Round($perfNet.BytesReceivedPersec / 1MB, 2)) MB/s" } else { 'N/A' } + TopCPU = $topCPU + TopMem = $topMem +} | ConvertTo-Json -Depth 4 -Compress`; + + const result = await runPowerShell(ps, { timeout: 30000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + const p = JSON.parse(result.stdout); + const topCpu = Array.isArray(p.TopCPU) ? p.TopCPU : [p.TopCPU].filter(Boolean); + const topMem = Array.isArray(p.TopMem) ? p.TopMem : [p.TopMem].filter(Boolean); + + return { + content: [{ + type: 'text', + text: [ + `CPU: ${p.CPUUsage} (${p.CPUCores} threads)`, + `RAM: ${p.RAMUsedGB}/${p.RAMTotalGB} GB (${p.RAMUsedPct}%)`, + `Disk: Read ${p.DiskReadBps} / Write ${p.DiskWriteBps}`, + `Net: Send ${p.NetSentBps} / Recv ${p.NetRecvBps}`, + '', + 'Top by CPU:', + ...topCpu.map((t: { Name: string; PID: number; CPU: number; MemMB: number }) => + ` ${String(t.PID).padStart(6)} ${t.Name.padEnd(20)} ${String(t.CPU).padStart(8)}s ${String(t.MemMB).padStart(8)} MB`, + ), + '', + 'Top by Memory:', + ...topMem.map((t: { Name: string; PID: number; MemMB: number }) => + ` ${String(t.PID).padStart(6)} ${t.Name.padEnd(20)} ${String(t.MemMB).padStart(8)} MB`, + ), + ].join('\n'), + }], + }; + }, + ); +} diff --git a/src/tools/printer.ts b/src/tools/printer.ts new file mode 100644 index 0000000..d35ea50 --- /dev/null +++ b/src/tools/printer.ts @@ -0,0 +1,114 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_printer_list (#50) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerPrinterTools(server: McpServer): void { + server.tool( + 'windows_printer_list', + 'List printers, show print queues, set default, or clear a queue.', + { + action: z.enum(['list', 'queue', 'set_default', 'clear_queue']).default('list').describe('Action'), + printer: z.string().optional().describe('Printer name (for queue/set_default/clear_queue)'), + }, + async ({ action, printer }) => { + switch (action) { + case 'list': { + const ps = ` +Get-Printer -ErrorAction SilentlyContinue | ForEach-Object { + $default = if ((Get-CimInstance Win32_Printer -Filter "Name='$($_.Name.Replace("'","''"))'" -ErrorAction SilentlyContinue).Default) { $true } else { $false } + [PSCustomObject]@{ + Name = $_.Name + Status = $_.PrinterStatus + Type = $_.Type + Port = $_.PortName + Driver = $_.DriverName + Default = $default + Shared = $_.Shared + } +} | ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps, { timeout: 45000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + if (!result.stdout) { + return { content: [{ type: 'text', text: 'No printers found.' }] }; + } + + const printers = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = printers.map((p: { Name: string; Status: string; Driver: string; Port: string; Default: boolean }) => { + const def = p.Default ? ' *' : ' '; + return `${def} ${(p.Name || '').padEnd(35).slice(0, 35)} ${(p.Driver || '').padEnd(25).slice(0, 25)} ${p.Port || ''}`; + }); + + const header = ` ${'Name'.padEnd(35)} ${'Driver'.padEnd(25)} Port`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(80)}\n${lines.join('\n')}\n\n${printers.length} printers (* = default)` }], + }; + } + + case 'queue': { + if (!printer) { + return { content: [{ type: 'text', text: 'Queue requires printer name.' }], isError: true }; + } + const ps = ` +Get-PrintJob -PrinterName '${printer.replace(/'/g, "''")}' -ErrorAction Stop | ForEach-Object { + [PSCustomObject]@{ + Id = $_.Id + Document = $_.DocumentName + Status = $_.JobStatus + Pages = $_.TotalPages + Size = "$([math]::Round($_.Size / 1KB, 1)) KB" + Submitted = $_.SubmittedTime.ToString('yyyy-MM-dd HH:mm') + } +} | 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: `Print queue for "${printer}" is empty.` }] }; + } + + const jobs = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = jobs.map((j: { Id: number; Document: string; Status: string; Pages: number; Size: string }) => + ` #${j.Id} ${(j.Document || '').padEnd(30).slice(0, 30)} ${(j.Status || '').padEnd(12)} ${j.Pages} pg ${j.Size}`, + ); + return { content: [{ type: 'text', text: `Queue for "${printer}":\n${lines.join('\n')}` }] }; + } + + case 'set_default': { + if (!printer) { + return { content: [{ type: 'text', text: 'set_default requires printer name.' }], isError: true }; + } + const ps = ` +$p = Get-CimInstance Win32_Printer -Filter "Name='${printer.replace(/'/g, "''")}'" -ErrorAction Stop +Invoke-CimMethod -InputObject $p -MethodName SetDefaultPrinter | Out-Null +"Default printer set to: ${printer}"`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + + case 'clear_queue': { + if (!printer) { + return { content: [{ type: 'text', text: 'clear_queue requires printer name.' }], isError: true }; + } + const ps = ` +$jobs = Get-PrintJob -PrinterName '${printer.replace(/'/g, "''")}' -ErrorAction Stop +$count = @($jobs).Count +$jobs | Remove-PrintJob -ErrorAction SilentlyContinue +"Cleared $count job(s) from ${printer}"`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + } + }, + ); +} diff --git a/src/tools/theme.ts b/src/tools/theme.ts new file mode 100644 index 0000000..473f83e --- /dev/null +++ b/src/tools/theme.ts @@ -0,0 +1,205 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_theme_get (#52), windows_theme_set (#53), + * windows_focus_mode (#55), windows_default_apps (#56) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerThemeTools(server: McpServer): void { + server.tool( + 'windows_theme_get', + 'Get current Windows theme: dark/light mode, accent color, wallpaper, transparency, taskbar alignment.', + {}, + async () => { + const ps = ` +$personalize = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize' +$accent = 'HKCU:\\Software\\Microsoft\\Windows\\DWM' +$taskbar = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced' +$wallpaper = (Get-ItemProperty -Path 'HKCU:\\Control Panel\\Desktop' -Name Wallpaper -ErrorAction SilentlyContinue).Wallpaper + +$appsDark = (Get-ItemProperty -Path $personalize -Name AppsUseLightTheme -ErrorAction SilentlyContinue).AppsUseLightTheme -eq 0 +$systemDark = (Get-ItemProperty -Path $personalize -Name SystemUsesLightTheme -ErrorAction SilentlyContinue).SystemUsesLightTheme -eq 0 +$transparency = (Get-ItemProperty -Path $personalize -Name EnableTransparency -ErrorAction SilentlyContinue).EnableTransparency -eq 1 +$accentColor = (Get-ItemProperty -Path $accent -Name AccentColor -ErrorAction SilentlyContinue).AccentColor +$taskbarAlign = (Get-ItemProperty -Path $taskbar -Name TaskbarAl -ErrorAction SilentlyContinue).TaskbarAl + +$accentHex = if ($accentColor) { + $b = ($accentColor -band 0xFF0000) -shr 16 + $g = ($accentColor -band 0x00FF00) -shr 8 + $r = ($accentColor -band 0x0000FF) + '#{0:X2}{1:X2}{2:X2}' -f $r, $g, $b +} else { 'Unknown' } + +[PSCustomObject]@{ + AppsDarkMode = $appsDark + SystemDarkMode = $systemDark + AccentColor = $accentHex + Transparency = $transparency + Wallpaper = $wallpaper + TaskbarAlignment = if ($taskbarAlign -eq 0) { 'Left' } else { 'Center' } +} | 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 t = JSON.parse(result.stdout); + return { + content: [{ + type: 'text', + text: [ + `Dark mode: Apps=${t.AppsDarkMode}, System=${t.SystemDarkMode}`, + `Accent color: ${t.AccentColor}`, + `Transparency: ${t.Transparency ? 'On' : 'Off'}`, + `Taskbar: ${t.TaskbarAlignment}`, + `Wallpaper: ${t.Wallpaper || '(none)'}`, + ].join('\n'), + }], + }; + }, + ); + + server.tool( + 'windows_theme_set', + 'Set dark/light mode, accent color, wallpaper, transparency, or taskbar alignment.', + { + dark_mode: z.enum(['on', 'off']).optional().describe('Set dark mode for apps and system'), + wallpaper: z.string().optional().describe('Wallpaper file path'), + wallpaper_fit: z.enum(['fill', 'fit', 'stretch', 'tile', 'center', 'span']).optional().describe('Wallpaper fit mode'), + transparency: z.enum(['on', 'off']).optional().describe('Transparency effects'), + taskbar_align: z.enum(['left', 'center']).optional().describe('Taskbar alignment'), + }, + async ({ dark_mode, wallpaper, wallpaper_fit, transparency, taskbar_align }) => { + const commands: string[] = []; + + if (dark_mode) { + const val = dark_mode === 'on' ? 0 : 1; + commands.push(`Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize' -Name AppsUseLightTheme -Value ${val}`); + commands.push(`Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize' -Name SystemUsesLightTheme -Value ${val}`); + commands.push(`"Dark mode: ${dark_mode}"`); + } + + if (transparency) { + const val = transparency === 'on' ? 1 : 0; + commands.push(`Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize' -Name EnableTransparency -Value ${val}`); + commands.push(`"Transparency: ${transparency}"`); + } + + if (taskbar_align) { + const val = taskbar_align === 'left' ? 0 : 1; + commands.push(`Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced' -Name TaskbarAl -Value ${val}`); + commands.push(`"Taskbar: ${taskbar_align}"`); + } + + if (wallpaper) { + const fitMap: Record = { fill: '10', fit: '6', stretch: '2', tile: '0', center: '0', span: '22' }; + const fit = wallpaper_fit || 'fill'; + commands.push(` +Set-ItemProperty -Path 'HKCU:\\Control Panel\\Desktop' -Name WallpaperStyle -Value '${fitMap[fit]}' +Set-ItemProperty -Path 'HKCU:\\Control Panel\\Desktop' -Name TileWallpaper -Value '${fit === 'tile' ? '1' : '0'}' +Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public class Wallpaper { [DllImport("user32.dll", CharSet=CharSet.Auto)] public static extern int SystemParametersInfo(int uAction, int uParam, string lpvParam, int fuWinIni); }' +[Wallpaper]::SystemParametersInfo(0x0014, 0, '${wallpaper.replace(/'/g, "''")}', 0x01 -bor 0x02) | Out-Null +"Wallpaper set: ${wallpaper} (${fit})" +`); + } + + if (commands.length === 0) { + return { content: [{ type: 'text', text: 'No changes specified.' }], isError: true }; + } + + const result = await runPowerShell(commands.join('\n'), { timeout: 15000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); + + server.tool( + 'windows_focus_mode', + 'Get or set Windows Focus Assist / Do Not Disturb mode.', + { + action: z.enum(['get', 'priority', 'alarms', 'off']).default('get').describe('Get status or set mode'), + }, + async ({ action }) => { + if (action === 'get') { + const ps = ` +$regPath = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\CloudStore\\Store\\DefaultAccount\\Current\\default$windows.data.notifications.quiethourssettings\\windows.data.notifications.quiethourssettings' +$mode = 'Unknown' +try { + $val = (Get-ItemProperty -Path $regPath -ErrorAction Stop).Data + if ($val) { + # The focus assist state is encoded in the binary blob + $mode = 'Check via Settings app (binary registry format)' + } +} catch { $mode = 'Off (or unable to read)' } +"Focus Assist: $mode"`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + + // Setting focus assist requires ms-settings URI + const ps = `Start-Process 'ms-settings:quiethours'; "Opened Focus Assist settings. Mode requested: ${action}"`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + }, + ); + + server.tool( + 'windows_default_apps', + 'Get default applications or open the default apps settings page.', + { + action: z.enum(['get', 'open_settings']).default('get').describe('Get defaults or open settings'), + extension: z.string().optional().describe('File extension to check (e.g. ".pdf")'), + }, + async ({ action, extension }) => { + if (action === 'open_settings') { + await runPowerShell('Start-Process "ms-settings:defaultapps"'); + return { content: [{ type: 'text', text: 'Opened Default Apps settings.' }] }; + } + + if (extension) { + const ps = ` +$assoc = cmd /c assoc ${extension} 2>$null +$ftype = if ($assoc) { $type = ($assoc -split '=')[1]; cmd /c ftype $type 2>$null } else { $null } +[PSCustomObject]@{ + Extension = '${extension}' + FileType = if ($assoc) { ($assoc -split '=')[1] } else { 'Not associated' } + OpensWith = if ($ftype) { ($ftype -split '=')[1] } else { 'Unknown' } +} | 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: `${info.Extension} -> ${info.FileType} -> ${info.OpensWith}` }] }; + } + + // List common defaults + const ps = ` +$defaults = @('.html','.pdf','.txt','.jpg','.png','.mp3','.mp4','.zip') | ForEach-Object { + $ext = $_ + $assoc = cmd /c assoc $ext 2>$null + $type = if ($assoc) { ($assoc -split '=')[1] } else { 'N/A' } + [PSCustomObject]@{ Extension = $ext; FileType = $type } +} +$defaults | 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 }; + } + + const defaults = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = defaults.map((d: { Extension: string; FileType: string }) => + ` ${d.Extension.padEnd(8)} ${d.FileType}`, + ); + return { content: [{ type: 'text', text: `Default file associations:\n${lines.join('\n')}` }] }; + }, + ); +} diff --git a/src/tools/usb.ts b/src/tools/usb.ts new file mode 100644 index 0000000..28ed90b --- /dev/null +++ b/src/tools/usb.ts @@ -0,0 +1,65 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_usb_devices (#49) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerUsbTools(server: McpServer): void { + server.tool( + 'windows_usb_devices', + 'List connected USB devices with name, manufacturer, type, and status. Supports safe eject for storage.', + { + eject: z.string().optional().describe('Drive letter to safely eject (e.g. "E:")'), + }, + async ({ eject }) => { + if (eject) { + const letter = eject.replace(':', '').toUpperCase(); + const ps = ` +$vol = Get-CimInstance Win32_Volume -Filter "DriveLetter='${letter}:'" -ErrorAction Stop +$ejectResult = $vol | Invoke-CimMethod -MethodName Dismount -Arguments @{Force=$false} -ErrorAction Stop +if ($ejectResult.ReturnValue -eq 0) { "Safely ejected ${letter}:" } else { "Eject failed (code: $($ejectResult.ReturnValue)). Close all files on the drive first." }`; + + const result = await runPowerShell(ps, { timeout: 15000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + } + + const ps = ` +Get-PnpDevice -Class USB -PresentOnly -ErrorAction SilentlyContinue | ForEach-Object { + [PSCustomObject]@{ + Name = $_.FriendlyName + Status = $_.Status + Manufacturer = $_.Manufacturer + InstanceId = $_.InstanceId + Class = $_.Class + } +} | Where-Object { $_.Name } | 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 USB devices found.' }] }; + } + + const devices = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = devices.map((d: { Name: string; Status: string; Manufacturer: string }) => { + const status = d.Status === 'OK' ? '[OK]' : `[${d.Status?.slice(0, 3) || '?'}]`; + return `${status} ${(d.Name || '').padEnd(45).slice(0, 45)} ${(d.Manufacturer || '').slice(0, 25)}`; + }); + + const header = `Sta ${'Name'.padEnd(45)} Manufacturer`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(80)}\n${lines.join('\n')}\n\n${devices.length} USB devices` }], + }; + }, + ); +} diff --git a/src/tools/virtual_desktop.ts b/src/tools/virtual_desktop.ts new file mode 100644 index 0000000..74b32c5 --- /dev/null +++ b/src/tools/virtual_desktop.ts @@ -0,0 +1,122 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_virtual_desktop (#54) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerVirtualDesktopTools(server: McpServer): void { + server.tool( + 'windows_virtual_desktop', + 'List, create, switch, or remove virtual desktops.', + { + action: z.enum(['list', 'create', 'switch', 'remove']).default('list').describe('Action'), + index: z.number().optional().describe('Desktop index (0-based, for switch/remove)'), + }, + async ({ action, index }) => { + switch (action) { + case 'list': { + const ps = ` +$desktops = Get-CimInstance -Namespace root\\cimv2 -ClassName Win32_Desktop -ErrorAction SilentlyContinue +# Virtual desktops are best accessed via COM, but we can detect count from registry +$vdKey = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\VirtualDesktops' +$ids = (Get-ItemProperty -Path $vdKey -Name VirtualDesktopIDs -ErrorAction SilentlyContinue).VirtualDesktopIDs +$currentId = (Get-ItemProperty -Path $vdKey -Name CurrentVirtualDesktop -ErrorAction SilentlyContinue).CurrentVirtualDesktop +$count = if ($ids) { $ids.Length / 16 } else { 1 } +[PSCustomObject]@{ + Count = $count + CurrentIndex = 'Use Ctrl+Win+Left/Right to navigate' + Note = 'Windows does not expose virtual desktop names via public API' +} | 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: `Virtual desktops: ${info.Count}\n${info.Note}` }] }; + } + + case 'create': { + // Use keyboard shortcut via SendKeys + const ps = ` +Add-Type @' +using System; +using System.Runtime.InteropServices; +public class VDKeys { + [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); + public static void NewDesktop() { + // Ctrl+Win+D + keybd_event(0x11, 0, 0, UIntPtr.Zero); // Ctrl down + keybd_event(0x5B, 0, 0, UIntPtr.Zero); // Win down + keybd_event(0x44, 0, 0, UIntPtr.Zero); // D down + keybd_event(0x44, 0, 2, UIntPtr.Zero); // D up + keybd_event(0x5B, 0, 2, UIntPtr.Zero); // Win up + keybd_event(0x11, 0, 2, UIntPtr.Zero); // Ctrl up + } +} +'@ +[VDKeys]::NewDesktop() +Start-Sleep -Milliseconds 500 +"New virtual desktop created (switched to it)"`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + + case 'switch': { + if (index === undefined) { + return { content: [{ type: 'text', text: 'Switch requires index.' }], isError: true }; + } + // Navigate left/right to reach target + const ps = ` +Add-Type @' +using System; +using System.Runtime.InteropServices; +public class VDSwitch { + [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); + public static void Left() { + keybd_event(0x11, 0, 0, UIntPtr.Zero); keybd_event(0x5B, 0, 0, UIntPtr.Zero); + keybd_event(0x25, 0, 0, UIntPtr.Zero); keybd_event(0x25, 0, 2, UIntPtr.Zero); + keybd_event(0x5B, 0, 2, UIntPtr.Zero); keybd_event(0x11, 0, 2, UIntPtr.Zero); + } + public static void Right() { + keybd_event(0x11, 0, 0, UIntPtr.Zero); keybd_event(0x5B, 0, 0, UIntPtr.Zero); + keybd_event(0x27, 0, 0, UIntPtr.Zero); keybd_event(0x27, 0, 2, UIntPtr.Zero); + keybd_event(0x5B, 0, 2, UIntPtr.Zero); keybd_event(0x11, 0, 2, UIntPtr.Zero); + } +} +'@ +# Go far left first, then right to target index +for ($i = 0; $i -lt 20; $i++) { [VDSwitch]::Left(); Start-Sleep -Milliseconds 100 } +for ($i = 0; $i -lt ${index}; $i++) { [VDSwitch]::Right(); Start-Sleep -Milliseconds 100 } +"Switched to desktop ${index}"`; + const result = await runPowerShell(ps, { timeout: 15000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + + case 'remove': { + const ps = ` +Add-Type @' +using System; +using System.Runtime.InteropServices; +public class VDClose { + [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); + public static void CloseDesktop() { + keybd_event(0x11, 0, 0, UIntPtr.Zero); keybd_event(0x5B, 0, 0, UIntPtr.Zero); + keybd_event(0x73, 0, 0, UIntPtr.Zero); // F4 + keybd_event(0x73, 0, 2, UIntPtr.Zero); + keybd_event(0x5B, 0, 2, UIntPtr.Zero); keybd_event(0x11, 0, 2, UIntPtr.Zero); + } +} +'@ +[VDClose]::CloseDesktop() +"Current virtual desktop removed"`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + } + }, + ); +} diff --git a/src/tools/wifi.ts b/src/tools/wifi.ts new file mode 100644 index 0000000..5138744 --- /dev/null +++ b/src/tools/wifi.ts @@ -0,0 +1,157 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_wifi_networks (#47), windows_wifi_connect (#48) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerWifiTools(server: McpServer): void { + server.tool( + 'windows_wifi_networks', + 'Scan and list available Wi-Fi networks with signal strength, security, and saved profiles.', + { + rescan: z.boolean().default(false).describe('Force a rescan before listing'), + saved: z.boolean().default(false).describe('List saved profiles instead of available networks'), + }, + async ({ rescan, saved }) => { + if (saved) { + const ps = `netsh wlan show profiles | Select-String 'All User Profile' | ForEach-Object { ($_ -split ':')[1].Trim() }`; + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + const profiles = result.stdout ? result.stdout.split('\n').filter(Boolean) : []; + return { + content: [{ type: 'text', text: `Saved Wi-Fi profiles (${profiles.length}):\n${profiles.map(p => ` ${p}`).join('\n')}` }], + }; + } + + const scanCmd = rescan ? `netsh wlan scan; Start-Sleep -Seconds 3;` : ''; + const ps = ` +${scanCmd} +$output = netsh wlan show networks mode=bssid 2>&1 +$networks = [System.Collections.Generic.List[PSObject]]::new() +$current = @{} + +foreach ($line in ($output -split [char]10)) { + $line = $line.Trim() + if ($line -match '^SSID \\d+ : (.*)') { + if ($current.SSID) { $networks.Add([PSCustomObject]$current) } + $current = @{ SSID = $Matches[1]; Signal = ''; Auth = ''; Channel = ''; BSSID = '' } + } + elseif ($line -match '^Signal\\s+: (.*)') { $current.Signal = $Matches[1] } + elseif ($line -match '^Authentication\\s+: (.*)') { $current.Auth = $Matches[1] } + elseif ($line -match '^Channel\\s+: (.*)') { $current.Channel = $Matches[1] } + elseif ($line -match '^BSSID \\d+\\s+: (.*)') { $current.BSSID = $Matches[1] } +} +if ($current.SSID) { $networks.Add([PSCustomObject]$current) } + +# Current connection +$connected = (netsh wlan show interfaces | Select-String 'SSID\\s+:' | Select-Object -First 1) -replace '.*:\\s*','' + +$networks | Sort-Object { [int]($_.Signal -replace '%','') } -Descending | ConvertTo-Json -Depth 3 -Compress +Write-Output "---CONNECTED:$($connected.Trim())"`; + + const result = await runPowerShell(ps, { timeout: 20000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + const outputLines = result.stdout.split('\n'); + const connectedLine = outputLines.find(l => l.startsWith('---CONNECTED:')); + const connected = connectedLine ? connectedLine.replace('---CONNECTED:', '').trim() : ''; + const jsonLine = outputLines.filter(l => !l.startsWith('---CONNECTED:')).join('\n'); + + let networks: Array<{ SSID: string; Signal: string; Auth: string; Channel: string }> = []; + if (jsonLine.trim()) { + try { + const parsed = JSON.parse(jsonLine); + networks = Array.isArray(parsed) ? parsed : [parsed]; + } catch { /* empty */ } + } + + const lines = networks.map(n => { + const icon = n.SSID === connected ? ' *' : ' '; + return `${icon} ${n.Signal.padStart(4)} ${n.Auth.padEnd(18)} Ch ${(n.Channel || '?').padEnd(3)} ${n.SSID}`; + }); + + const header = ` ${'Sig'.padStart(4)} ${'Security'.padEnd(18)} ${'Ch'.padEnd(5)} SSID`; + return { + content: [{ + type: 'text', + text: `Connected: ${connected || '(none)'}\n\n${header}\n${'─'.repeat(65)}\n${lines.join('\n')}\n\n${networks.length} networks (* = connected)`, + }], + }; + }, + ); + + server.tool( + 'windows_wifi_connect', + 'Connect to a Wi-Fi network, disconnect, or forget a saved profile.', + { + action: z.enum(['connect', 'disconnect', 'forget']).describe('Action'), + ssid: z.string().optional().describe('Network SSID (for connect/forget)'), + password: z.string().optional().describe('Password (for connecting to a new network)'), + }, + async ({ action, ssid, password }) => { + let ps: string; + + switch (action) { + case 'disconnect': + ps = `netsh wlan disconnect; "Disconnected from Wi-Fi"`; + break; + + case 'forget': + if (!ssid) { + return { content: [{ type: 'text', text: 'Forget requires ssid.' }], isError: true }; + } + ps = `netsh wlan delete profile name="${ssid.replace(/"/g, '""')}" 2>&1; "Forgot network: ${ssid}"`; + break; + + case 'connect': + if (!ssid) { + return { content: [{ type: 'text', text: 'Connect requires ssid.' }], isError: true }; + } + // Check if profile exists + if (password) { + // Create a temporary profile XML for new networks + ps = ` +$profileXml = @" + + + ${ssid} + ${ssid} + ESS + auto + + WPA2PSKAESfalse + passPhrasefalse${password} + + +"@ +$tempFile = [IO.Path]::GetTempFileName() +$profileXml | Out-File -Encoding UTF8 $tempFile +netsh wlan add profile filename="$tempFile" 2>&1 | Out-Null +Remove-Item $tempFile +netsh wlan connect name="${ssid.replace(/"/g, '""')}" 2>&1 +Start-Sleep -Seconds 3 +$iface = netsh wlan show interfaces +$connectedSsid = ($iface | Select-String 'SSID\\s+:' | Select-Object -First 1) -replace '.*:\\s*','' +if ($connectedSsid.Trim() -eq '${ssid}') { "Connected to ${ssid}" } else { "Connection attempt sent for ${ssid}" }`; + } else { + ps = `netsh wlan connect name="${ssid.replace(/"/g, '""')}" 2>&1; Start-Sleep -Seconds 2; "Connect attempt sent for ${ssid}"`; + } + break; + } + + const result = await runPowerShell(ps, { timeout: 20000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +}