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