feat: implement v1.3 admin tools + v1.4 advanced (13 new tools)
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Changelog Validation / Validate CHANGELOG.md (pull_request) Has been cancelled
MCP: Copilot Agent / Run Copilot Coding Agent (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: PR Check / Changelog Updated (pull_request) Has been cancelled
MCP: Build & Validate / build (20) (pull_request) Has been cancelled
MCP: Build & Validate / build (22) (pull_request) Has been cancelled
MCP: Standards Compliance / Secret Scanning (pull_request) Has been cancelled
MCP: Standards Compliance / License Header Validation (pull_request) Has been cancelled
MCP: Standards Compliance / Repository Structure Validation (pull_request) Has been cancelled
MCP: Standards Compliance / Coding Standards Check (pull_request) Has been cancelled
MCP: Standards Compliance / Workflow Configuration Check (pull_request) Has been cancelled
MCP: Standards Compliance / Documentation Quality Check (pull_request) Has been cancelled
MCP: Standards Compliance / README Completeness Check (pull_request) Has been cancelled
MCP: Standards Compliance / Git Repository Hygiene (pull_request) Has been cancelled
MCP: Standards Compliance / File Naming Standards (pull_request) Has been cancelled
MCP: Standards Compliance / Insecure Code Pattern Detection (pull_request) Has been cancelled
MCP: Standards Compliance / Line Length Check (pull_request) Has been cancelled
MCP: Standards Compliance / Script Integrity Validation (pull_request) Has been cancelled
MCP: Standards Compliance / File Size Limits (pull_request) Has been cancelled
MCP: Standards Compliance / Dead Code Detection (pull_request) Has been cancelled
MCP: Standards Compliance / Binary File Detection (pull_request) Has been cancelled
MCP: Standards Compliance / TODO/FIXME Tracking (pull_request) Has been cancelled
MCP: Standards Compliance / Broken Link Detection (pull_request) Has been cancelled
MCP: Standards Compliance / API Documentation Coverage (pull_request) Has been cancelled
MCP: Standards Compliance / Accessibility Check (pull_request) Has been cancelled
MCP: Standards Compliance / Performance Metrics (pull_request) Has been cancelled
Universal: Auto-Assign / Assign unassigned issues and PRs (pull_request_target) Has been cancelled
MCP: Standards Compliance / Version Consistency Check (pull_request) Has been cancelled
MCP: Standards Compliance / Code Complexity Analysis (pull_request) Has been cancelled
MCP: Standards Compliance / Code Duplication Detection (pull_request) Has been cancelled
Universal: CodeQL Analysis / Analyze (actions) (pull_request) Has been cancelled
MCP: Standards Compliance / Terraform Configuration Validation (pull_request) Has been cancelled
Universal: CodeQL Analysis / Analyze (javascript) (pull_request) Has been cancelled
MCP: Standards Compliance / Unused Dependencies Check (pull_request) Has been cancelled
MCP: Standards Compliance / Enterprise Readiness Check (pull_request) Has been cancelled
MCP: Standards Compliance / Repository Health Check (pull_request) Has been cancelled
Universal: CodeQL Analysis / Security Scan Summary (pull_request) Has been cancelled
MCP: Standards Compliance / Dependency Vulnerability Scanning (pull_request) Has been cancelled
MCP: Standards Compliance / Compliance Summary (pull_request) Has been cancelled
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Changelog Validation / Validate CHANGELOG.md (pull_request) Has been cancelled
MCP: Copilot Agent / Run Copilot Coding Agent (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: PR Check / Changelog Updated (pull_request) Has been cancelled
MCP: Build & Validate / build (20) (pull_request) Has been cancelled
MCP: Build & Validate / build (22) (pull_request) Has been cancelled
MCP: Standards Compliance / Secret Scanning (pull_request) Has been cancelled
MCP: Standards Compliance / License Header Validation (pull_request) Has been cancelled
MCP: Standards Compliance / Repository Structure Validation (pull_request) Has been cancelled
MCP: Standards Compliance / Coding Standards Check (pull_request) Has been cancelled
MCP: Standards Compliance / Workflow Configuration Check (pull_request) Has been cancelled
MCP: Standards Compliance / Documentation Quality Check (pull_request) Has been cancelled
MCP: Standards Compliance / README Completeness Check (pull_request) Has been cancelled
MCP: Standards Compliance / Git Repository Hygiene (pull_request) Has been cancelled
MCP: Standards Compliance / File Naming Standards (pull_request) Has been cancelled
MCP: Standards Compliance / Insecure Code Pattern Detection (pull_request) Has been cancelled
MCP: Standards Compliance / Line Length Check (pull_request) Has been cancelled
MCP: Standards Compliance / Script Integrity Validation (pull_request) Has been cancelled
MCP: Standards Compliance / File Size Limits (pull_request) Has been cancelled
MCP: Standards Compliance / Dead Code Detection (pull_request) Has been cancelled
MCP: Standards Compliance / Binary File Detection (pull_request) Has been cancelled
MCP: Standards Compliance / TODO/FIXME Tracking (pull_request) Has been cancelled
MCP: Standards Compliance / Broken Link Detection (pull_request) Has been cancelled
MCP: Standards Compliance / API Documentation Coverage (pull_request) Has been cancelled
MCP: Standards Compliance / Accessibility Check (pull_request) Has been cancelled
MCP: Standards Compliance / Performance Metrics (pull_request) Has been cancelled
Universal: Auto-Assign / Assign unassigned issues and PRs (pull_request_target) Has been cancelled
MCP: Standards Compliance / Version Consistency Check (pull_request) Has been cancelled
MCP: Standards Compliance / Code Complexity Analysis (pull_request) Has been cancelled
MCP: Standards Compliance / Code Duplication Detection (pull_request) Has been cancelled
Universal: CodeQL Analysis / Analyze (actions) (pull_request) Has been cancelled
MCP: Standards Compliance / Terraform Configuration Validation (pull_request) Has been cancelled
Universal: CodeQL Analysis / Analyze (javascript) (pull_request) Has been cancelled
MCP: Standards Compliance / Unused Dependencies Check (pull_request) Has been cancelled
MCP: Standards Compliance / Enterprise Readiness Check (pull_request) Has been cancelled
MCP: Standards Compliance / Repository Health Check (pull_request) Has been cancelled
Universal: CodeQL Analysis / Security Scan Summary (pull_request) Has been cancelled
MCP: Standards Compliance / Dependency Vulnerability Scanning (pull_request) Has been cancelled
MCP: Standards Compliance / Compliance Summary (pull_request) Has been cancelled
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
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
This commit is contained in:
@@ -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<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* 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` }],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* 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<McpConfig> {
|
||||
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<void> {
|
||||
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<string, unknown>)[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<string, unknown>)[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}` }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* 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<string, number> = { 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 }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* 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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* 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<string, string> = {
|
||||
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` }],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* 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> = {
|
||||
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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* 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 }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* 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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user