From 032c50aa46ee15b8ae9651b42382d557f6ec064e Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 25 May 2026 21:39:05 -0500 Subject: [PATCH] feat: implement v1.3 admin tools + v1.4 advanced (13 new tools) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.3 — Admin Tools: - tools/scheduler.ts: windows_task_scheduler_list, _manage (#27, #28) - tools/registry.ts: windows_registry_read, _write (#29, #30) - tools/environment.ts: windows_env_get, _set (#31, #32) - tools/startup.ts: windows_startup_list, _manage (#33, #34) - tools/config.ts: windows_mcp_config (#40) v1.4 — Advanced: - tools/apps.ts: windows_installed_apps (#19) - tools/dialog.ts: windows_dialog (#21) - tools/netstat.ts: windows_network_connections (#23) - tools/recycle_bin.ts: windows_recycle_bin (#26) All 40 issues implemented. 44 tools total (some issues split into sub-tools like terminal_start/send/read/list/kill). Authored-by: Moko Consulting --- src/index.ts | 22 +++++ src/tools/apps.ts | 76 +++++++++++++++++ src/tools/config.ts | 105 +++++++++++++++++++++++ src/tools/dialog.ts | 87 +++++++++++++++++++ src/tools/environment.ts | 146 +++++++++++++++++++++++++++++++ src/tools/netstat.ts | 72 ++++++++++++++++ src/tools/recycle_bin.ts | 126 +++++++++++++++++++++++++++ src/tools/registry.ts | 165 +++++++++++++++++++++++++++++++++++ src/tools/scheduler.ts | 140 ++++++++++++++++++++++++++++++ src/tools/startup.ts | 180 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 1119 insertions(+) create mode 100644 src/tools/apps.ts create mode 100644 src/tools/config.ts create mode 100644 src/tools/dialog.ts create mode 100644 src/tools/environment.ts create mode 100644 src/tools/netstat.ts create mode 100644 src/tools/recycle_bin.ts create mode 100644 src/tools/registry.ts create mode 100644 src/tools/scheduler.ts create mode 100644 src/tools/startup.ts diff --git a/src/index.ts b/src/index.ts index 0d6a805..9970c1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,15 @@ import { registerDisplayTools } from './tools/display.js'; import { registerWindowTools } from './tools/window.js'; import { registerClipboardTools } from './tools/clipboard.js'; import { registerNotificationTools } from './tools/notification.js'; +import { registerSchedulerTools } from './tools/scheduler.js'; +import { registerRegistryTools } from './tools/registry.js'; +import { registerEnvironmentTools } from './tools/environment.js'; +import { registerStartupTools } from './tools/startup.js'; +import { registerConfigTools } from './tools/config.js'; +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'; const server = new McpServer({ name: 'mcp_windows', @@ -51,6 +60,19 @@ registerWindowTools(server); registerClipboardTools(server); registerNotificationTools(server); +// v1.3 — Admin Tools +registerSchedulerTools(server); +registerRegistryTools(server); +registerEnvironmentTools(server); +registerStartupTools(server); +registerConfigTools(server); + +// v1.4 — Advanced +registerAppsTools(server); +registerDialogTools(server); +registerNetstatTools(server); +registerRecycleBinTools(server); + async function main(): Promise { const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/tools/apps.ts b/src/tools/apps.ts new file mode 100644 index 0000000..e70b240 --- /dev/null +++ b/src/tools/apps.ts @@ -0,0 +1,76 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_installed_apps (#19) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerAppsTools(server: McpServer): void { + server.tool( + 'windows_installed_apps', + 'List installed applications from registry and Microsoft Store.', + { + filter: z.string().optional().describe('Filter by app name (substring)'), + sort: z.enum(['name', 'date', 'size']).default('name').describe('Sort order'), + limit: z.number().default(50).describe('Max results'), + }, + async ({ filter, sort, limit }) => { + const filterClause = filter + ? `| Where-Object { $_.DisplayName -like '*${filter.replace(/'/g, "''")}*' }` + : ''; + + const sortClause = sort === 'date' + ? '| Sort-Object InstallDate -Descending' + : sort === 'size' + ? '| Sort-Object { [int]$_.EstimatedSize } -Descending' + : '| Sort-Object DisplayName'; + + const ps = ` +$regPaths = @( + 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*', + 'HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*', + 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' +) + +$apps = $regPaths | ForEach-Object { + Get-ItemProperty -Path $_ -ErrorAction SilentlyContinue +} | Where-Object { $_.DisplayName } ${filterClause} ${sortClause} | + Select-Object -First ${limit} | + ForEach-Object { + [PSCustomObject]@{ + Name = $_.DisplayName + Version = $_.DisplayVersion + Publisher = $_.Publisher + InstallDate = $_.InstallDate + SizeMB = if ($_.EstimatedSize) { [math]::Round($_.EstimatedSize / 1024, 1) } else { $null } + } + } + +$apps | 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 apps found.' }] }; + } + + const apps = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = apps.map((a: { Name: string; Version: string; Publisher: string; InstallDate: string; SizeMB: number | null }) => { + const size = a.SizeMB ? `${String(a.SizeMB).padStart(8)} MB` : ' '; + const date = a.InstallDate || ' '; + return `${(a.Name || '').padEnd(45).slice(0, 45)} ${(a.Version || '').padEnd(15).slice(0, 15)} ${date.padEnd(10)} ${size} ${(a.Publisher || '').slice(0, 25)}`; + }); + + const header = `${'Name'.padEnd(45)} ${'Version'.padEnd(15)} ${'Installed'.padEnd(10)} ${'Size'.padStart(11)} Publisher`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(115)}\n${lines.join('\n')}\n\n${apps.length} applications` }], + }; + }, + ); +} diff --git a/src/tools/config.ts b/src/tools/config.ts new file mode 100644 index 0000000..0f96637 --- /dev/null +++ b/src/tools/config.ts @@ -0,0 +1,105 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_mcp_config (#40) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { homedir } from 'node:os'; + +const CONFIG_PATH = resolve(homedir(), '.mcp_windows.json'); + +interface McpConfig { + blockedCommands: string[]; + allowedDirectories: string[]; + outputLineLimit: number; + commandTimeout: number; +} + +const DEFAULT_CONFIG: McpConfig = { + blockedCommands: ['Format-Volume', 'Clear-Disk', 'Remove-Partition'], + allowedDirectories: [], + outputLineLimit: 5000, + commandTimeout: 30000, +}; + +let currentConfig: McpConfig | null = null; + +async function loadConfig(): Promise { + if (currentConfig) return currentConfig; + try { + const raw = await readFile(CONFIG_PATH, 'utf-8'); + currentConfig = { ...DEFAULT_CONFIG, ...JSON.parse(raw) }; + } catch { + currentConfig = { ...DEFAULT_CONFIG }; + } + return currentConfig!; +} + +async function saveConfig(config: McpConfig): Promise { + await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8'); + currentConfig = config; +} + +export function registerConfigTools(server: McpServer): void { + server.tool( + 'windows_mcp_config', + 'Get or set mcp_windows configuration (blocked commands, allowed directories, output limits).', + { + action: z.enum(['get', 'set']).default('get').describe('Get or set config'), + key: z.string().optional().describe('Config key to set (blockedCommands, allowedDirectories, outputLineLimit, commandTimeout)'), + value: z.string().optional().describe('Value to set (JSON for arrays, number for limits)'), + }, + async ({ action, key, value }) => { + const config = await loadConfig(); + + if (action === 'get') { + return { + content: [{ + type: 'text', + text: [ + `mcp_windows configuration (${CONFIG_PATH}):`, + ``, + `Blocked commands: ${config.blockedCommands.join(', ') || '(none)'}`, + `Allowed directories: ${config.allowedDirectories.length > 0 ? config.allowedDirectories.join(', ') : '(unrestricted)'}`, + `Output line limit: ${config.outputLineLimit}`, + `Command timeout: ${config.commandTimeout}ms`, + ].join('\n'), + }], + }; + } + + if (!key || value === undefined) { + return { content: [{ type: 'text', text: 'Set requires key and value.' }], isError: true }; + } + + switch (key) { + case 'blockedCommands': + case 'allowedDirectories': + try { + (config as unknown as Record)[key] = JSON.parse(value); + } catch { + return { content: [{ type: 'text', text: `Value must be a JSON array (e.g. ["cmd1","cmd2"])` }], isError: true }; + } + break; + case 'outputLineLimit': + case 'commandTimeout': { + const num = Number(value); + if (isNaN(num) || num < 0) { + return { content: [{ type: 'text', text: 'Value must be a positive number.' }], isError: true }; + } + (config as unknown as Record)[key] = num; + break; + } + default: + return { content: [{ type: 'text', text: `Unknown key: ${key}. Valid: blockedCommands, allowedDirectories, outputLineLimit, commandTimeout` }], isError: true }; + } + + await saveConfig(config); + return { content: [{ type: 'text', text: `Set ${key} = ${value}` }] }; + }, + ); +} diff --git a/src/tools/dialog.ts b/src/tools/dialog.ts new file mode 100644 index 0000000..16345a6 --- /dev/null +++ b/src/tools/dialog.ts @@ -0,0 +1,87 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_dialog (#21) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerDialogTools(server: McpServer): void { + server.tool( + 'windows_dialog', + 'Show system dialogs: message box, input prompt, file/folder picker.', + { + type: z.enum(['message', 'input', 'file_open', 'file_save', 'folder']).describe('Dialog type'), + title: z.string().default('mcp_windows').describe('Dialog title'), + message: z.string().optional().describe('Message text (for message/input)'), + buttons: z.enum(['ok', 'okcancel', 'yesno', 'yesnocancel']).default('ok').describe('Buttons (for message)'), + filter: z.string().optional().describe('File filter (for file dialogs, e.g. "Text files|*.txt|All files|*.*")'), + default_path: z.string().optional().describe('Default path/filename'), + }, + async ({ type, title, message, buttons, filter, default_path }) => { + let ps: string; + + switch (type) { + case 'message': { + const btnMap: Record = { ok: 0, okcancel: 1, yesno: 4, yesnocancel: 3 }; + ps = ` +Add-Type -AssemblyName System.Windows.Forms +$result = [System.Windows.Forms.MessageBox]::Show('${(message || '').replace(/'/g, "''")}', '${title.replace(/'/g, "''")}', ${btnMap[buttons]}) +$result.ToString()`; + break; + } + + case 'input': + ps = ` +Add-Type -AssemblyName Microsoft.VisualBasic +$result = [Microsoft.VisualBasic.Interaction]::InputBox('${(message || 'Enter value:').replace(/'/g, "''")}', '${title.replace(/'/g, "''")}', '${(default_path || '').replace(/'/g, "''")}') +if ($result) { $result } else { '__CANCELLED__' }`; + break; + + case 'file_open': + ps = ` +Add-Type -AssemblyName System.Windows.Forms +$dlg = New-Object System.Windows.Forms.OpenFileDialog +$dlg.Title = '${title.replace(/'/g, "''")}' +${filter ? `$dlg.Filter = '${filter.replace(/'/g, "''")}'` : ''} +${default_path ? `$dlg.InitialDirectory = '${default_path.replace(/'/g, "''")}'` : ''} +$dlg.Multiselect = $false +if ($dlg.ShowDialog() -eq 'OK') { $dlg.FileName } else { '__CANCELLED__' }`; + break; + + case 'file_save': + ps = ` +Add-Type -AssemblyName System.Windows.Forms +$dlg = New-Object System.Windows.Forms.SaveFileDialog +$dlg.Title = '${title.replace(/'/g, "''")}' +${filter ? `$dlg.Filter = '${filter.replace(/'/g, "''")}'` : ''} +${default_path ? `$dlg.FileName = '${default_path.replace(/'/g, "''")}'` : ''} +if ($dlg.ShowDialog() -eq 'OK') { $dlg.FileName } else { '__CANCELLED__' }`; + break; + + case 'folder': + ps = ` +Add-Type -AssemblyName System.Windows.Forms +$dlg = New-Object System.Windows.Forms.FolderBrowserDialog +$dlg.Description = '${(message || title).replace(/'/g, "''")}' +${default_path ? `$dlg.SelectedPath = '${default_path.replace(/'/g, "''")}'` : ''} +if ($dlg.ShowDialog() -eq 'OK') { $dlg.SelectedPath } else { '__CANCELLED__' }`; + break; + } + + const result = await runPowerShell(ps, { timeout: 120000 }); // Long timeout — user interaction + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + const output = result.stdout.trim(); + if (output === '__CANCELLED__') { + return { content: [{ type: 'text', text: 'Dialog cancelled by user.' }] }; + } + + return { content: [{ type: 'text', text: output }] }; + }, + ); +} diff --git a/src/tools/environment.ts b/src/tools/environment.ts new file mode 100644 index 0000000..f2df6b6 --- /dev/null +++ b/src/tools/environment.ts @@ -0,0 +1,146 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_env_get (#31), windows_env_set (#32) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerEnvironmentTools(server: McpServer): void { + server.tool( + 'windows_env_get', + 'Get environment variables. Can retrieve a specific variable or list all (user, system, or both).', + { + name: z.string().optional().describe('Variable name (omit to list all)'), + scope: z.enum(['user', 'system', 'both']).default('both').describe('Variable scope'), + }, + async ({ name, scope }) => { + if (name) { + if (name.toUpperCase() === 'PATH') { + const ps = ` +$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') +$sysPath = [Environment]::GetEnvironmentVariable('PATH', 'Machine') +[PSCustomObject]@{ + UserPATH = ($userPath -split ';' | Where-Object { $_ }) + SystemPATH = ($sysPath -split ';' | Where-Object { $_ }) +} | 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 p = JSON.parse(result.stdout); + const userPaths = Array.isArray(p.UserPATH) ? p.UserPATH : [p.UserPATH].filter(Boolean); + const sysPaths = Array.isArray(p.SystemPATH) ? p.SystemPATH : [p.SystemPATH].filter(Boolean); + return { + content: [{ + type: 'text', + text: `System PATH (${sysPaths.length} entries):\n${sysPaths.map((p: string) => ` ${p}`).join('\n')}\n\nUser PATH (${userPaths.length} entries):\n${userPaths.map((p: string) => ` ${p}`).join('\n')}`, + }], + }; + } + + const ps = ` +$user = [Environment]::GetEnvironmentVariable('${name.replace(/'/g, "''")}', 'User') +$sys = [Environment]::GetEnvironmentVariable('${name.replace(/'/g, "''")}', 'Machine') +$proc = [Environment]::GetEnvironmentVariable('${name.replace(/'/g, "''")}', 'Process') +[PSCustomObject]@{ Name = '${name.replace(/'/g, "''")}'; User = $user; System = $sys; Process = $proc } | 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 v = JSON.parse(result.stdout); + const lines = [`${v.Name}:`]; + if (v.System !== null) lines.push(` System: ${v.System}`); + if (v.User !== null) lines.push(` User: ${v.User}`); + if (v.Process !== null && v.Process !== v.System && v.Process !== v.User) lines.push(` Process: ${v.Process}`); + if (v.System === null && v.User === null) lines.push(' (not set)'); + return { content: [{ type: 'text', text: lines.join('\n') }] }; + } + + // List all + const scopes = scope === 'both' ? ['User', 'Machine'] : [scope === 'user' ? 'User' : 'Machine']; + const parts: string[] = []; + + for (const s of scopes) { + const ps = `[Environment]::GetEnvironmentVariables('${s}').GetEnumerator() | Sort-Object Name | ForEach-Object { [PSCustomObject]@{ Name = $_.Name; Value = $_.Value } } | ConvertTo-Json -Depth 3 -Compress`; + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) continue; + if (!result.stdout) continue; + + const vars = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = vars.map((v: { Name: string; Value: string }) => + ` ${v.Name.padEnd(30)} ${(v.Value || '').slice(0, 60)}`, + ); + parts.push(`${s} Variables (${vars.length}):\n${lines.join('\n')}`); + } + + return { content: [{ type: 'text', text: parts.join('\n\n') }] }; + }, + ); + + server.tool( + 'windows_env_set', + 'Set or remove a persistent environment variable (user or system scope).', + { + name: z.string().describe('Variable name'), + value: z.string().optional().describe('Value to set (omit with action=remove to delete)'), + scope: z.enum(['user', 'system']).default('user').describe('Variable scope'), + action: z.enum(['set', 'remove', 'append_path', 'prepend_path']).default('set').describe('Action'), + }, + async ({ name, value, scope, action }) => { + const target = scope === 'user' ? 'User' : 'Machine'; + let ps: string; + + switch (action) { + case 'set': + if (!value) { + return { content: [{ type: 'text', text: 'Set requires a value.' }], isError: true }; + } + ps = `[Environment]::SetEnvironmentVariable('${name.replace(/'/g, "''")}', '${value.replace(/'/g, "''")}', '${target}'); "Set ${name}=${value} (${target})"`; + break; + case 'remove': + ps = `[Environment]::SetEnvironmentVariable('${name.replace(/'/g, "''")}', $null, '${target}'); "Removed ${name} (${target})"`; + break; + case 'append_path': + if (!value) { + return { content: [{ type: 'text', text: 'append_path requires a value.' }], isError: true }; + } + ps = ` +$current = [Environment]::GetEnvironmentVariable('PATH', '${target}') +$entries = $current -split ';' | Where-Object { $_ } +if ('${value.replace(/'/g, "''")}' -notin $entries) { + $new = ($entries + '${value.replace(/'/g, "''")}') -join ';' + [Environment]::SetEnvironmentVariable('PATH', $new, '${target}') + "Appended '${value}' to ${target} PATH" +} else { + "'${value}' already in ${target} PATH" +}`; + break; + case 'prepend_path': + if (!value) { + return { content: [{ type: 'text', text: 'prepend_path requires a value.' }], isError: true }; + } + ps = ` +$current = [Environment]::GetEnvironmentVariable('PATH', '${target}') +$entries = $current -split ';' | Where-Object { $_ } +if ('${value.replace(/'/g, "''")}' -notin $entries) { + $new = ('${value.replace(/'/g, "''")}' + ';' + ($entries -join ';')) + [Environment]::SetEnvironmentVariable('PATH', $new, '${target}') + "Prepended '${value}' to ${target} PATH" +} else { + "'${value}' already in ${target} PATH" +}`; + break; + } + + const result = await runPowerShell(ps, { timeout: 10000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +} diff --git a/src/tools/netstat.ts b/src/tools/netstat.ts new file mode 100644 index 0000000..9fc8968 --- /dev/null +++ b/src/tools/netstat.ts @@ -0,0 +1,72 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_network_connections (#23) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerNetstatTools(server: McpServer): void { + server.tool( + 'windows_network_connections', + 'List active TCP/UDP network connections (like netstat). Shows local/remote address, state, and owning process.', + { + state: z.enum(['all', 'listen', 'established', 'time_wait', 'close_wait']).default('all').describe('Filter by state'), + port: z.number().optional().describe('Filter by port number'), + process_name: z.string().optional().describe('Filter by process name'), + limit: z.number().default(50).describe('Max results'), + }, + async ({ state, port, process_name, limit }) => { + const stateMap: Record = { + listen: "| Where-Object { $_.State -eq 'Listen' }", + established: "| Where-Object { $_.State -eq 'Established' }", + time_wait: "| Where-Object { $_.State -eq 'TimeWait' }", + close_wait: "| Where-Object { $_.State -eq 'CloseWait' }", + all: '', + }; + + const portFilter = port + ? `| Where-Object { $_.LocalPort -eq ${port} -or $_.RemotePort -eq ${port} }` + : ''; + + const procFilter = process_name + ? `| Where-Object { $procName -like '*${process_name.replace(/'/g, "''")}*' }` + : ''; + + const ps = ` +Get-NetTCPConnection -ErrorAction SilentlyContinue ${stateMap[state]} ${portFilter} | ForEach-Object { + $proc = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue + $procName = if ($proc) { $proc.ProcessName } else { '?' } + [PSCustomObject]@{ + Proto = 'TCP' + LocalAddr = "$($_.LocalAddress):$($_.LocalPort)" + RemoteAddr = "$($_.RemoteAddress):$($_.RemotePort)" + State = $_.State.ToString() + PID = $_.OwningProcess + Process = $procName + } +} ${procFilter} | Select-Object -First ${limit} | 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 connections found.' }] }; + } + + const conns = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = conns.map((c: { Proto: string; LocalAddr: string; RemoteAddr: string; State: string; PID: number; Process: string }) => + `${c.Proto} ${c.LocalAddr.padEnd(22)} ${c.RemoteAddr.padEnd(22)} ${c.State.padEnd(12)} ${String(c.PID).padStart(6)} ${c.Process}`, + ); + + const header = `Proto ${'Local Address'.padEnd(22)} ${'Remote Address'.padEnd(22)} ${'State'.padEnd(12)} ${'PID'.padStart(6)} Process`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(100)}\n${lines.join('\n')}\n\n${conns.length} connections` }], + }; + }, + ); +} diff --git a/src/tools/recycle_bin.ts b/src/tools/recycle_bin.ts new file mode 100644 index 0000000..c0cad40 --- /dev/null +++ b/src/tools/recycle_bin.ts @@ -0,0 +1,126 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_recycle_bin (#26) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerRecycleBinTools(server: McpServer): void { + server.tool( + 'windows_recycle_bin', + 'Manage the Recycle Bin: list items, get size, restore items, or empty.', + { + action: z.enum(['list', 'size', 'empty', 'restore']).default('list').describe('Action'), + filter: z.string().optional().describe('Filter by filename (for list/restore)'), + limit: z.number().default(30).describe('Max items (for list)'), + }, + async ({ action, filter, limit }) => { + switch (action) { + case 'size': { + const ps = ` +$shell = New-Object -ComObject Shell.Application +$bin = $shell.Namespace(10) +$items = $bin.Items() +$count = $items.Count +$totalSize = 0 +for ($i = 0; $i -lt $count; $i++) { + $totalSize += $bin.GetDetailsOf($items.Item($i), 2) -replace '[^0-9]','' -as [long] +} +[PSCustomObject]@{ + Count = $count + SizeMB = [math]::Round($totalSize / 1MB, 1) +} | 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: `Recycle Bin: ${info.Count} items, ${info.SizeMB} MB` }] }; + } + + case 'list': { + const filterClause = filter + ? `| Where-Object { $_.Name -like '*${filter.replace(/'/g, "''")}*' }` + : ''; + + const ps = ` +$shell = New-Object -ComObject Shell.Application +$bin = $shell.Namespace(10) +$items = @() +foreach ($item in $bin.Items()) { + $items += [PSCustomObject]@{ + Name = $item.Name + OriginalPath = $bin.GetDetailsOf($item, 1) + Size = $bin.GetDetailsOf($item, 2) + DeletedDate = $bin.GetDetailsOf($item, 3) + Type = $bin.GetDetailsOf($item, 4) + } +} +$items ${filterClause} | Select-Object -First ${limit} | 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: 'Recycle Bin is empty.' }] }; + } + + const items = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = items.map((i: { Name: string; OriginalPath: string; Size: string; DeletedDate: string }) => + `${(i.Name || '').padEnd(35).slice(0, 35)} ${(i.Size || '').padStart(10)} ${(i.DeletedDate || '').padEnd(20)} ${(i.OriginalPath || '').slice(0, 40)}`, + ); + + const header = `${'Name'.padEnd(35)} ${'Size'.padStart(10)} ${'Deleted'.padEnd(20)} Original Path`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(110)}\n${lines.join('\n')}\n\n${items.length} items` }], + }; + } + + case 'empty': { + const ps = ` +$shell = New-Object -ComObject Shell.Application +$count = $shell.Namespace(10).Items().Count +if ($count -eq 0) { "Recycle Bin is already empty." } +else { + Clear-RecycleBin -Force -ErrorAction Stop + "Emptied Recycle Bin ($count items removed)" +}`; + const result = await runPowerShell(ps, { timeout: 15000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + } + + case 'restore': { + if (!filter) { + return { content: [{ type: 'text', text: 'Restore requires a filter to identify which item(s) to restore.' }], isError: true }; + } + const ps = ` +$shell = New-Object -ComObject Shell.Application +$bin = $shell.Namespace(10) +$restored = 0 +foreach ($item in $bin.Items()) { + if ($item.Name -like '*${filter.replace(/'/g, "''")}*') { + $origPath = $bin.GetDetailsOf($item, 1) + $item.InvokeVerb('undelete') + $restored++ + } +} +if ($restored -gt 0) { "Restored $restored item(s)" } else { "No matching items found" }`; + + 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/registry.ts b/src/tools/registry.ts new file mode 100644 index 0000000..bbfa31b --- /dev/null +++ b/src/tools/registry.ts @@ -0,0 +1,165 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_registry_read (#29), windows_registry_write (#30) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +function expandHive(path: string): string { + return path + .replace(/^HKLM[:\\\/]/i, 'HKLM:\\') + .replace(/^HKCU[:\\\/]/i, 'HKCU:\\') + .replace(/^HKCR[:\\\/]/i, 'HKCR:\\') + .replace(/^HKU[:\\\/]/i, 'HKU:\\') + .replace(/^HKCC[:\\\/]/i, 'HKCC:\\'); +} + +export function registerRegistryTools(server: McpServer): void { + server.tool( + 'windows_registry_read', + 'Read Windows Registry keys, subkeys, and values. Supports HKLM, HKCU, HKCR abbreviations.', + { + path: z.string().describe('Registry path (e.g. "HKCU:\\Software\\Microsoft")'), + value: z.string().optional().describe('Specific value name to read (omit to list all values)'), + subkeys: z.boolean().default(false).describe('List subkeys instead of values'), + }, + async ({ path, value, subkeys }) => { + const regPath = expandHive(path); + + if (subkeys) { + const ps = ` +Get-ChildItem -Path '${regPath.replace(/'/g, "''")}' -ErrorAction Stop | ForEach-Object { + [PSCustomObject]@{ + Name = $_.PSChildName + SubKeyCount = $_.SubKeyCount + ValueCount = $_.ValueCount + } +} | 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 subkeys found.' }] }; + } + + const keys = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = keys.map((k: { Name: string; SubKeyCount: number; ValueCount: number }) => + ` ${k.Name.padEnd(40)} ${k.SubKeyCount} subkeys, ${k.ValueCount} values`, + ); + return { content: [{ type: 'text', text: `${regPath}\n${'─'.repeat(70)}\n${lines.join('\n')}` }] }; + } + + if (value) { + const ps = ` +$v = Get-ItemProperty -Path '${regPath.replace(/'/g, "''")}' -Name '${value.replace(/'/g, "''")}' -ErrorAction Stop +$raw = $v.'${value.replace(/'/g, "''")}' +$kind = (Get-Item -Path '${regPath.replace(/'/g, "''")}' -ErrorAction Stop).GetValueKind('${value.replace(/'/g, "''")}') +[PSCustomObject]@{ + Name = '${value.replace(/'/g, "''")}' + Type = $kind.ToString() + Value = $raw +} | 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 v = JSON.parse(result.stdout); + return { content: [{ type: 'text', text: `${regPath}\\${v.Name}\nType: ${v.Type}\nValue: ${JSON.stringify(v.Value)}` }] }; + } + + // List all values + const ps = ` +$key = Get-Item -Path '${regPath.replace(/'/g, "''")}' -ErrorAction Stop +$key.GetValueNames() | ForEach-Object { + $name = $_ + $val = $key.GetValue($name) + $kind = $key.GetValueKind($name) + [PSCustomObject]@{ + Name = if ($name) { $name } else { '(Default)' } + Type = $kind.ToString() + Value = $val + } +} | 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 values found.' }] }; + } + + const values = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = values.map((v: { Name: string; Type: string; Value: unknown }) => { + const valStr = typeof v.Value === 'string' ? v.Value : JSON.stringify(v.Value); + return ` ${v.Name.padEnd(30)} ${v.Type.padEnd(12)} ${valStr.slice(0, 60)}`; + }); + + const header = ` ${'Name'.padEnd(30)} ${'Type'.padEnd(12)} Value`; + return { content: [{ type: 'text', text: `${regPath}\n${header}\n${'─'.repeat(80)}\n${lines.join('\n')}` }] }; + }, + ); + + server.tool( + 'windows_registry_write', + 'Write Windows Registry values. Restricted to HKCU by default. Use hklm_override for HKLM writes.', + { + path: z.string().describe('Registry path'), + name: z.string().describe('Value name'), + value: z.string().describe('Value data'), + type: z.enum(['String', 'DWord', 'QWord', 'Binary', 'ExpandString', 'MultiString']).default('String').describe('Value type'), + hklm_override: z.boolean().default(false).describe('Allow writing to HKLM (requires elevation)'), + action: z.enum(['set', 'delete', 'create_key', 'delete_key']).default('set').describe('Action'), + }, + async ({ path, name, value, type, hklm_override, action }) => { + const regPath = expandHive(path); + + // Safety check + if (regPath.startsWith('HKLM:') && !hklm_override) { + return { + content: [{ type: 'text', text: 'HKLM writes are restricted. Set hklm_override=true and ensure elevation.' }], + isError: true, + }; + } + + let ps: string; + + switch (action) { + case 'set': { + const typeMap: Record = { + String: 'String', DWord: 'DWord', QWord: 'QWord', + Binary: 'Binary', ExpandString: 'ExpandString', MultiString: 'MultiString', + }; + ps = ` +if (-not (Test-Path '${regPath.replace(/'/g, "''")}')) { + New-Item -Path '${regPath.replace(/'/g, "''")}' -Force | Out-Null +} +Set-ItemProperty -Path '${regPath.replace(/'/g, "''")}' -Name '${name.replace(/'/g, "''")}' -Value '${value.replace(/'/g, "''")}' -Type ${typeMap[type]} -ErrorAction Stop +"Set ${regPath}\\${name} = ${value} (${type})"`; + break; + } + case 'delete': + ps = `Remove-ItemProperty -Path '${regPath.replace(/'/g, "''")}' -Name '${name.replace(/'/g, "''")}' -ErrorAction Stop; "Deleted ${regPath}\\${name}"`; + break; + case 'create_key': + ps = `New-Item -Path '${regPath.replace(/'/g, "''")}' -Force -ErrorAction Stop | Out-Null; "Created key ${regPath}"`; + break; + case 'delete_key': + ps = `Remove-Item -Path '${regPath.replace(/'/g, "''")}' -Recurse -Force -ErrorAction Stop; "Deleted key ${regPath}"`; + break; + } + + const result = await runPowerShell(ps, { timeout: 10000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +} diff --git a/src/tools/scheduler.ts b/src/tools/scheduler.ts new file mode 100644 index 0000000..46bfdf9 --- /dev/null +++ b/src/tools/scheduler.ts @@ -0,0 +1,140 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_task_scheduler_list (#27), windows_task_scheduler_manage (#28) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerSchedulerTools(server: McpServer): void { + server.tool( + 'windows_task_scheduler_list', + 'List Windows Task Scheduler tasks with status, last/next run, and trigger type.', + { + folder: z.string().default('\\').describe('Task folder path (e.g. "\\" for root, "\\Microsoft\\")'), + filter: z.string().optional().describe('Filter by task name (substring)'), + }, + async ({ folder, filter }) => { + const filterClause = filter + ? `| Where-Object { $_.TaskName -like '*${filter.replace(/'/g, "''")}*' }` + : ''; + + const ps = ` +Get-ScheduledTask -TaskPath '${folder.replace(/'/g, "''")}*' -ErrorAction SilentlyContinue ${filterClause} | ForEach-Object { + $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue + [PSCustomObject]@{ + Name = $_.TaskName + Path = $_.TaskPath + State = $_.State.ToString() + LastRun = if ($info.LastRunTime -and $info.LastRunTime.Year -gt 1999) { $info.LastRunTime.ToString('yyyy-MM-dd HH:mm') } else { 'Never' } + NextRun = if ($info.NextRunTime -and $info.NextRunTime.Year -gt 1999) { $info.NextRunTime.ToString('yyyy-MM-dd HH:mm') } else { 'N/A' } + LastResult = if ($info) { '0x{0:X}' -f $info.LastTaskResult } else { 'N/A' } + Triggers = ($_.Triggers | ForEach-Object { $_.CimClass.CimClassName -replace 'MSFT_Task',''-replace 'Trigger','' }) -join ', ' + } +} | 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 tasks found.' }] }; + } + + const tasks = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = tasks.map((t: { Name: string; State: string; LastRun: string; NextRun: string; Triggers: string }) => { + const state = t.State === 'Ready' ? '[RDY]' : t.State === 'Running' ? '[RUN]' : t.State === 'Disabled' ? '[OFF]' : `[${t.State.slice(0, 3).toUpperCase()}]`; + return `${state} ${t.Name.padEnd(40).slice(0, 40)} ${t.LastRun.padEnd(16)} ${t.NextRun.padEnd(16)} ${t.Triggers}`; + }); + + const header = `State ${'Name'.padEnd(40)} ${'Last Run'.padEnd(16)} ${'Next Run'.padEnd(16)} Triggers`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(110)}\n${lines.join('\n')}\n\n${tasks.length} tasks` }], + }; + }, + ); + + server.tool( + 'windows_task_scheduler_manage', + 'Create, delete, enable, disable, or run a scheduled task.', + { + action: z.enum(['create', 'delete', 'enable', 'disable', 'run']).describe('Action to perform'), + name: z.string().describe('Task name'), + command: z.string().optional().describe('Command to execute (for create)'), + arguments: z.string().optional().describe('Command arguments (for create)'), + trigger: z.enum(['once', 'daily', 'weekly', 'hourly', 'logon', 'startup']).optional().describe('Trigger type (for create)'), + time: z.string().optional().describe('Time for trigger as HH:mm (for create with once/daily/weekly)'), + interval: z.number().optional().describe('Repetition interval in minutes (for create with hourly)'), + }, + async ({ action, name, command, arguments: args, trigger, time, interval }) => { + let ps: string; + + switch (action) { + case 'run': + ps = `Start-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -ErrorAction Stop; "Task '${name}' started"`; + break; + case 'enable': + ps = `Enable-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -ErrorAction Stop | Select-Object TaskName,State | ConvertTo-Json -Compress`; + break; + case 'disable': + ps = `Disable-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -ErrorAction Stop | Select-Object TaskName,State | ConvertTo-Json -Compress`; + break; + case 'delete': + ps = `Unregister-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -Confirm:$false -ErrorAction Stop; "Task '${name}' deleted"`; + break; + case 'create': { + if (!command) { + return { content: [{ type: 'text', text: 'Create requires command.' }], isError: true }; + } + if (!trigger) { + return { content: [{ type: 'text', text: 'Create requires trigger type.' }], isError: true }; + } + + const actionPart = args + ? `$action = New-ScheduledTaskAction -Execute '${command.replace(/'/g, "''")}' -Argument '${args.replace(/'/g, "''")}'` + : `$action = New-ScheduledTaskAction -Execute '${command.replace(/'/g, "''")}'`; + + let triggerPart: string; + switch (trigger) { + case 'once': + triggerPart = `$trigger = New-ScheduledTaskTrigger -Once -At '${time || '00:00'}'`; + break; + case 'daily': + triggerPart = `$trigger = New-ScheduledTaskTrigger -Daily -At '${time || '00:00'}'`; + break; + case 'weekly': + triggerPart = `$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At '${time || '00:00'}'`; + break; + case 'hourly': + triggerPart = `$trigger = New-ScheduledTaskTrigger -Once -At '00:00' -RepetitionInterval (New-TimeSpan -Minutes ${interval || 60}) -RepetitionDuration (New-TimeSpan -Days 9999)`; + break; + case 'logon': + triggerPart = `$trigger = New-ScheduledTaskTrigger -AtLogOn`; + break; + case 'startup': + triggerPart = `$trigger = New-ScheduledTaskTrigger -AtStartup`; + break; + } + + ps = ` +${actionPart} +${triggerPart} +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable +Register-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -Action $action -Trigger $trigger -Settings $settings -Force -ErrorAction Stop | + Select-Object TaskName,State | ConvertTo-Json -Compress`; + break; + } + } + + const result = await runPowerShell(ps, { timeout: 15000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + return { content: [{ type: 'text', text: result.stdout }] }; + }, + ); +} diff --git a/src/tools/startup.ts b/src/tools/startup.ts new file mode 100644 index 0000000..e413133 --- /dev/null +++ b/src/tools/startup.ts @@ -0,0 +1,180 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_startup_list (#33), windows_startup_manage (#34) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerStartupTools(server: McpServer): void { + server.tool( + 'windows_startup_list', + 'List all startup items from registry (Run/RunOnce), startup folder, and scheduled logon tasks.', + {}, + async () => { + const ps = ` +$items = [System.Collections.Generic.List[PSObject]]::new() + +# Registry: HKCU Run +$regPaths = @( + @{ Path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; Scope = 'User'; Source = 'Registry Run' } + @{ Path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce'; Scope = 'User'; Source = 'Registry RunOnce' } + @{ Path = 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; Scope = 'System'; Source = 'Registry Run' } + @{ Path = 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce'; Scope = 'System'; Source = 'Registry RunOnce' } +) + +foreach ($rp in $regPaths) { + if (Test-Path $rp.Path) { + $props = Get-ItemProperty -Path $rp.Path -ErrorAction SilentlyContinue + if ($props) { + $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object { + $items.Add([PSCustomObject]@{ + Name = $_.Name + Command = $_.Value + Source = $rp.Source + Scope = $rp.Scope + Enabled = $true + }) + } + } + } +} + +# Startup folder +$userStartup = [Environment]::GetFolderPath('Startup') +$commonStartup = [Environment]::GetFolderPath('CommonStartup') +foreach ($folder in @(@{Path=$userStartup;Scope='User'}, @{Path=$commonStartup;Scope='System'})) { + if (Test-Path $folder.Path) { + Get-ChildItem -Path $folder.Path -File -ErrorAction SilentlyContinue | ForEach-Object { + $items.Add([PSCustomObject]@{ + Name = $_.BaseName + Command = $_.FullName + Source = 'Startup Folder' + Scope = $folder.Scope + Enabled = $true + }) + } + } +} + +# Scheduled tasks at logon +Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { + $_.Triggers | Where-Object { $_ -is [CimInstance] -and $_.CimClass.CimClassName -eq 'MSFT_TaskLogonTrigger' } +} | ForEach-Object { + $items.Add([PSCustomObject]@{ + Name = $_.TaskName + Command = ($_.Actions | ForEach-Object { $_.Execute }) -join ' ' + Source = 'Task Scheduler (Logon)' + Scope = 'System' + Enabled = $_.State -eq 'Ready' + }) +} + +$items | 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 startup items found.' }] }; + } + + const items = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = items.map((i: { Name: string; Command: string; Source: string; Scope: string; Enabled: boolean }) => { + const status = i.Enabled ? '[ON] ' : '[OFF]'; + return `${status} ${i.Scope.padEnd(6)} ${i.Source.padEnd(24)} ${i.Name.padEnd(30).slice(0, 30)} ${(i.Command || '').slice(0, 50)}`; + }); + + const header = `State ${'Scope'.padEnd(6)} ${'Source'.padEnd(24)} ${'Name'.padEnd(30)} Command`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(110)}\n${lines.join('\n')}\n\n${items.length} startup items` }], + }; + }, + ); + + server.tool( + 'windows_startup_manage', + 'Add, remove, enable, or disable a startup item.', + { + action: z.enum(['add', 'remove', 'enable', 'disable']).describe('Action'), + name: z.string().describe('Startup item name'), + command: z.string().optional().describe('Command to run at startup (for add)'), + location: z.enum(['registry', 'startup_folder']).default('registry').describe('Where to add (for add)'), + }, + async ({ action, name, command, location }) => { + let ps: string; + + switch (action) { + case 'add': + if (!command) { + return { content: [{ type: 'text', text: 'Add requires a command.' }], isError: true }; + } + if (location === 'startup_folder') { + ps = ` +$startupPath = [Environment]::GetFolderPath('Startup') +$shortcutPath = Join-Path $startupPath '${name.replace(/'/g, "''")}.lnk' +$ws = New-Object -ComObject WScript.Shell +$sc = $ws.CreateShortcut($shortcutPath) +$sc.TargetPath = '${command.replace(/'/g, "''")}' +$sc.Save() +"Added startup shortcut: $shortcutPath"`; + } else { + ps = ` +Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name '${name.replace(/'/g, "''")}' -Value '${command.replace(/'/g, "''")}' -ErrorAction Stop +"Added to HKCU Run: ${name}"`; + } + break; + + case 'remove': + ps = ` +$removed = $false +# Try registry +$regPath = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' +if ((Get-ItemProperty -Path $regPath -Name '${name.replace(/'/g, "''")}' -ErrorAction SilentlyContinue)) { + Remove-ItemProperty -Path $regPath -Name '${name.replace(/'/g, "''")}' -ErrorAction Stop + $removed = $true +} +# Try startup folder +$startupPath = [Environment]::GetFolderPath('Startup') +$lnk = Join-Path $startupPath '${name.replace(/'/g, "''")}.lnk' +if (Test-Path $lnk) { Remove-Item $lnk -Force; $removed = $true } +if ($removed) { "Removed: ${name}" } else { "Not found: ${name}" }`; + break; + + case 'disable': + ps = ` +$regPath = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' +$val = (Get-ItemProperty -Path $regPath -Name '${name.replace(/'/g, "''")}' -ErrorAction SilentlyContinue).'${name.replace(/'/g, "''")}' +if ($val) { + Remove-ItemProperty -Path $regPath -Name '${name.replace(/'/g, "''")}' -ErrorAction Stop + $disabledPath = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run_Disabled' + if (-not (Test-Path $disabledPath)) { New-Item -Path $disabledPath -Force | Out-Null } + Set-ItemProperty -Path $disabledPath -Name '${name.replace(/'/g, "''")}' -Value $val + "Disabled: ${name}" +} else { "Not found in registry Run: ${name}" }`; + break; + + case 'enable': + ps = ` +$disabledPath = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run_Disabled' +$val = (Get-ItemProperty -Path $disabledPath -Name '${name.replace(/'/g, "''")}' -ErrorAction SilentlyContinue).'${name.replace(/'/g, "''")}' +if ($val) { + Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name '${name.replace(/'/g, "''")}' -Value $val + Remove-ItemProperty -Path $disabledPath -Name '${name.replace(/'/g, "''")}' -ErrorAction SilentlyContinue + "Enabled: ${name}" +} else { "Not found in disabled items: ${name}" }`; + break; + } + + const result = await runPowerShell(ps, { timeout: 10000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +}