diff --git a/ISSUES.md b/ISSUES.md new file mode 100644 index 0000000..5c30606 --- /dev/null +++ b/ISSUES.md @@ -0,0 +1,601 @@ +# mcp_windows — Feature Issues + +Issues to create on Gitea once repo is published. +Labels: `type: feature`, `priority: normal` unless noted otherwise. + +--- + +## Category: Terminal & Process Execution + +### Issue 1: Tool — `windows_execute` +**Labels:** `type: feature`, `priority: high` + +Execute shell commands (PowerShell, cmd, bash) with intelligent completion detection. Support background execution, timeout, and working directory. + +**Acceptance criteria:** +- Execute PowerShell commands by default +- Support `shell` param: `pwsh`, `cmd`, `bash` +- Support `timeout` param (ms) +- Support `cwd` (working directory) +- Support `background` flag for long-running commands +- Return stdout, stderr, exit code +- Detect hung/interactive prompts + +--- + +### Issue 2: Tool — `windows_process_list` +**Labels:** `type: feature`, `priority: high` + +List running processes with PID, name, CPU%, memory usage, window title, and path. + +**Acceptance criteria:** +- Return all running processes +- Include: PID, name, CPU%, memory (MB), window title, executable path +- Support `filter` param (name substring match) +- Support `sort` param (cpu, memory, name) + +--- + +### Issue 3: Tool — `windows_process_kill` +**Labels:** `type: feature`, `priority: normal` + +Terminate a running process by PID or name. + +**Acceptance criteria:** +- Kill by PID (single or array) +- Kill by name (with confirmation count) +- Support `force` flag for immediate termination +- Return success/failure per process + +--- + +### Issue 4: Tool — `windows_service_list` +**Labels:** `type: feature`, `priority: normal` + +List Windows services with status, startup type, and description. + +**Acceptance criteria:** +- Return all services (or filtered by status/name) +- Include: name, display name, status, startup type, description +- Support `filter` param (name match) +- Support `status` param (running, stopped, all) + +--- + +### Issue 5: Tool — `windows_service_control` +**Labels:** `type: feature`, `priority: normal` + +Start, stop, restart, or change startup type of Windows services. + +**Acceptance criteria:** +- Actions: start, stop, restart, enable, disable +- Support service name or display name +- Return new service status after action +- Require elevation indicator for protected services + +--- + +## Category: Audio & Volume Control + +### Issue 6: Tool — `windows_audio_get` +**Labels:** `type: feature`, `priority: high` + +Get current audio state: master volume level, mute status, default device. + +**Acceptance criteria:** +- Return master volume (0-100) +- Return mute state (boolean) +- Return default playback device name +- Return list of available audio devices + +--- + +### Issue 7: Tool — `windows_audio_set` +**Labels:** `type: feature`, `priority: high` + +Set audio volume, mute/unmute, or change default audio device. + +**Acceptance criteria:** +- Set master volume (0-100) +- Set mute state (true/false/toggle) +- Set default playback device by name +- Return new state after change + +--- + +### Issue 8: Tool — `windows_audio_app_volumes` +**Labels:** `type: feature`, `priority: normal` + +Get and set per-application volume levels. + +**Acceptance criteria:** +- List all apps with active audio sessions +- Get volume/mute per app +- Set volume/mute per app +- Identify apps by name or PID + +--- + +## Category: Display & Monitor + +### Issue 9: Tool — `windows_display_get` +**Labels:** `type: feature`, `priority: normal` + +Get display configuration: resolution, refresh rate, scaling, multi-monitor layout. + +**Acceptance criteria:** +- List all connected monitors +- Per monitor: resolution, refresh rate, scaling %, position, primary flag +- Include display name/model +- Report HDR status + +--- + +### Issue 10: Tool — `windows_display_set` +**Labels:** `type: feature`, `priority: normal` + +Change display settings: resolution, refresh rate, scaling, brightness. + +**Acceptance criteria:** +- Set resolution per monitor +- Set refresh rate +- Set brightness (where supported) +- Set scaling percentage +- Return new settings after change + +--- + +### Issue 11: Tool — `windows_screenshot` +**Labels:** `type: feature`, `priority: normal` + +Capture screenshot of screen, window, or region. + +**Acceptance criteria:** +- Capture full screen (specify monitor) +- Capture specific window by title/PID +- Capture region (x, y, width, height) +- Return as base64 or save to file path +- Support format: png, jpg + +--- + +## Category: Power Management + +### Issue 12: Tool — `windows_power_get` +**Labels:** `type: feature`, `priority: normal` + +Get power state: battery level, AC/battery, power plan, screen timeout settings. + +**Acceptance criteria:** +- Battery percentage and charging status +- Current power plan name +- Screen/sleep timeout values +- Estimated time remaining (battery) + +--- + +### Issue 13: Tool — `windows_power_action` +**Labels:** `type: feature`, `priority: normal` + +Execute power actions: sleep, hibernate, lock, shutdown, restart, schedule. + +**Acceptance criteria:** +- Actions: sleep, hibernate, lock, shutdown, restart, log-off +- Support `delay` param (seconds) +- Support `cancel` to abort scheduled action +- Support power plan switch (balanced, performance, power saver) + +--- + +## Category: Window Management + +### Issue 14: Tool — `windows_window_list` +**Labels:** `type: feature`, `priority: normal` + +List all open windows with title, position, size, state. + +**Acceptance criteria:** +- List visible windows +- Include: title, PID, process name, position (x,y), size (w,h), state (minimized/maximized/normal) +- Support `filter` by title or process name +- Include z-order (front to back) + +--- + +### Issue 15: Tool — `windows_window_control` +**Labels:** `type: feature`, `priority: normal` + +Move, resize, minimize, maximize, close, or focus windows. + +**Acceptance criteria:** +- Actions: minimize, maximize, restore, close, focus, move, resize +- Identify window by title (substring) or PID +- Move: set x, y position +- Resize: set width, height +- Support `topmost` flag (always on top) + +--- + +## Category: Clipboard + +### Issue 16: Tool — `windows_clipboard_get` +**Labels:** `type: feature`, `priority: normal` + +Read clipboard contents (text, file paths, image). + +**Acceptance criteria:** +- Get text content +- Get file list (when files are copied) +- Get image as base64 (when image is copied) +- Report content type available + +--- + +### Issue 17: Tool — `windows_clipboard_set` +**Labels:** `type: feature`, `priority: normal` + +Set clipboard contents. + +**Acceptance criteria:** +- Set text content +- Set file list (for paste-as-files) +- Set image from base64 or file path +- Clear clipboard + +--- + +## Category: System Information + +### Issue 18: Tool — `windows_system_info` +**Labels:** `type: feature`, `priority: high` + +Get comprehensive system information. + +**Acceptance criteria:** +- OS version, build, edition +- CPU: model, cores, usage % +- RAM: total, available, used % +- Disk: per-drive total, free, usage % +- Network: adapters, IPs, connection status +- Uptime +- Hostname, username, domain + +--- + +### Issue 19: Tool — `windows_installed_apps` +**Labels:** `type: feature`, `priority: low` + +List installed applications. + +**Acceptance criteria:** +- List apps from registry + Store apps +- Include: name, version, publisher, install date, size +- Support `filter` param +- Support `sort` param (name, date, size) + +--- + +## Category: Notifications & UI + +### Issue 20: Tool — `windows_notification_send` +**Labels:** `type: feature`, `priority: normal` + +Send Windows toast notifications. + +**Acceptance criteria:** +- Title and body text +- Support icon (file path) +- Support action buttons +- Support expiration time +- Optional sound + +--- + +### Issue 21: Tool — `windows_dialog` +**Labels:** `type: feature`, `priority: low` + +Show system dialog boxes (message box, input, file picker). + +**Acceptance criteria:** +- Message box with configurable buttons (OK, Yes/No, etc.) +- Input dialog (text prompt) +- File open/save dialog with filters +- Folder picker +- Return user selection + +--- + +## Category: Network + +### Issue 22: Tool — `windows_network_info` +**Labels:** `type: feature`, `priority: normal` + +Get network configuration and status. + +**Acceptance criteria:** +- List adapters: name, type, status, IP, MAC, speed +- DNS servers +- Default gateway +- Wi-Fi: SSID, signal strength, security +- Current internet connectivity status + +--- + +### Issue 23: Tool — `windows_network_connections` +**Labels:** `type: feature`, `priority: low` + +List active network connections (like netstat). + +**Acceptance criteria:** +- List TCP/UDP connections +- Include: local addr:port, remote addr:port, state, PID, process name +- Support filter by state, port, process +- Support `listen` flag (only listening ports) + +--- + +## Category: File System (Enhanced) + +### Issue 24: Tool — `windows_drives` +**Labels:** `type: feature`, `priority: normal` + +List drives/volumes with type, label, capacity, free space. + +**Acceptance criteria:** +- All mounted drives (local, network, removable) +- Include: letter, label, type, filesystem, total, free, used % +- Detect USB/removable vs fixed vs network + +--- + +### Issue 25: Tool — `windows_file_search` +**Labels:** `type: feature`, `priority: normal` + +Search files using Windows Search index (instant results for indexed locations). + +**Acceptance criteria:** +- Search by name pattern (glob or regex) +- Search by content (indexed content search) +- Filter by date range, size, type +- Use Windows Search index when available +- Fallback to filesystem walk for non-indexed paths +- Return: path, size, modified date, type + +--- + +### Issue 26: Tool — `windows_recycle_bin` +**Labels:** `type: feature`, `priority: low` + +Manage the Recycle Bin. + +**Acceptance criteria:** +- List items (name, original path, size, deleted date) +- Restore item(s) +- Empty bin (all or selected) +- Get bin size/count + +--- + +## Category: Scheduled Tasks + +### Issue 27: Tool — `windows_task_scheduler_list` +**Labels:** `type: feature`, `priority: normal` + +List Windows Task Scheduler tasks. + +**Acceptance criteria:** +- List all tasks or filter by folder/name +- Include: name, status, last run, next run, trigger type +- Support folder navigation + +--- + +### Issue 28: Tool — `windows_task_scheduler_manage` +**Labels:** `type: feature`, `priority: normal` + +Create, delete, enable, disable, or run scheduled tasks. + +**Acceptance criteria:** +- Create task: name, command, trigger (time, interval, event), run level +- Delete task by name +- Enable/disable task +- Run task immediately +- Modify existing task triggers + +--- + +## Category: Registry + +### Issue 29: Tool — `windows_registry_read` +**Labels:** `type: feature`, `priority: low` + +Read Windows Registry keys and values. + +**Acceptance criteria:** +- Read value by full path (HKLM, HKCU, etc.) +- List subkeys of a key +- List values of a key +- Return value type (REG_SZ, DWORD, etc.) +- Support common abbreviations (HKLM, HKCU, HKCR) + +--- + +### Issue 30: Tool — `windows_registry_write` +**Labels:** `type: feature`, `priority: low`, `priority: caution` + +Write Windows Registry keys and values. + +**Acceptance criteria:** +- Set value (string, dword, binary, expandsz, multi_sz) +- Create key +- Delete value +- Delete key (with confirmation) +- Backup key before modification +- Restricted to HKCU by default (HKLM requires explicit flag) + +--- + +## Category: Environment & Configuration + +### Issue 31: Tool — `windows_env_get` +**Labels:** `type: feature`, `priority: normal` + +Get environment variables (user, system, process). + +**Acceptance criteria:** +- Get specific variable by name +- List all variables (user, system, or both) +- Show PATH as parsed list +- Indicate scope (user vs system) + +--- + +### Issue 32: Tool — `windows_env_set` +**Labels:** `type: feature`, `priority: normal` + +Set environment variables persistently (user or system scope). + +**Acceptance criteria:** +- Set user-scope variable +- Set system-scope variable (requires elevation) +- Append/prepend to PATH +- Remove variable +- Changes persist across sessions + +--- + +## Category: Startup & Autorun + +### Issue 33: Tool — `windows_startup_list` +**Labels:** `type: feature`, `priority: normal` + +List applications configured to run at startup. + +**Acceptance criteria:** +- Registry Run/RunOnce (HKLM + HKCU) +- Startup folder items +- Scheduled tasks set to run at logon +- Task Manager startup tab equivalent +- Include: name, command, location, enabled status + +--- + +### Issue 34: Tool — `windows_startup_manage` +**Labels:** `type: feature`, `priority: normal` + +Enable, disable, or add startup items. + +**Acceptance criteria:** +- Disable/enable existing startup item +- Add new startup item (registry or startup folder) +- Remove startup item +- Set startup delay + +--- + +## Category: Desktop Commander Parity (Terminal) + +### Issue 35: Tool — `windows_terminal_session` +**Labels:** `type: feature`, `priority: high` + +Persistent interactive terminal sessions (REPL, SSH, etc.) with output pagination. + +**Acceptance criteria:** +- Start persistent session (pwsh, cmd, python, node, wsl) +- Send input to session +- Read output with offset/length pagination +- List active sessions +- Terminate session +- Detect prompt/completion state +- Context overflow protection (configurable line limit) + +--- + +### Issue 36: Tool — `windows_file_read` +**Labels:** `type: feature`, `priority: high` + +Read files with smart pagination, format detection, and URL support. + +**Acceptance criteria:** +- Text files with line offset/length pagination +- PDF text extraction +- Excel: sheet selection, range support +- DOCX: outline mode +- Images: base64 encoding +- URL fetching (isUrl flag) +- Binary file detection +- Negative offset for tail behavior + +--- + +### Issue 37: Tool — `windows_file_write` +**Labels:** `type: feature`, `priority: high` + +Write files with format support and chunking. + +**Acceptance criteria:** +- Text write/append +- Excel write (JSON 2D array → .xlsx) +- DOCX creation from markdown +- PDF creation from markdown +- Chunked writing for large files +- Create parent directories if needed + +--- + +### Issue 38: Tool — `windows_file_edit` +**Labels:** `type: feature`, `priority: high` + +Surgical file edits with find/replace. + +**Acceptance criteria:** +- Find and replace text (single or all occurrences) +- Expected replacement count validation +- Character-level diff on near-matches +- Line-range replacement +- Regex support +- Dry-run mode + +--- + +### Issue 39: Tool — `windows_search` +**Labels:** `type: feature`, `priority: high` + +Search files by name or content with streaming results. + +**Acceptance criteria:** +- Search by filename pattern (glob) +- Search by file content (regex or literal) +- Case sensitivity toggle +- File type filter +- Exclude patterns +- Context lines around matches +- Result pagination +- Background/streaming mode for large searches + +--- + +## Category: Configuration + +### Issue 40: Tool — `windows_mcp_config` +**Labels:** `type: feature`, `priority: normal` + +Get and set mcp_windows configuration. + +**Acceptance criteria:** +- Get current config (allowed paths, blocked commands, limits) +- Set values dynamically without restart +- Configurable: blocked commands, allowed directories, output line limits +- Persist config to `~/.mcp_windows.json` + +--- + +## Milestone Plan + +| Milestone | Issues | Priority | +|-----------|--------|----------| +| **v1.0 — Core** | #1, #2, #6, #7, #18, #35-39 | High | +| **v1.1 — System Control** | #3-5, #8, #12-13, #22, #24 | Normal | +| **v1.2 — Desktop Automation** | #9-11, #14-17, #20 | Normal | +| **v1.3 — Admin Tools** | #27-34, #40 | Normal | +| **v1.4 — Advanced** | #19, #21, #23, #25-26 | Low | diff --git a/src/client.ts b/src/client.ts deleted file mode 100644 index 4337947..0000000 --- a/src/client.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* Copyright (C) 2026 Moko Consulting - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: mcp_windows.Client - * INGROUP: mcp_windows - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mcp_windows - * PATH: /src/client.ts - * VERSION: 01.00.00 - * BRIEF: HTTP client for Windows Desktop API - */ - -import * as https from 'node:https'; -import * as http from 'node:http'; -import type { ApiConnection, ApiResponse } from './types.js'; - -// ── Customize these ───────────────────────────────────────────────────── -// API path prefix appended to baseUrl (e.g. "/api/index.php", "/api/v1") -const API_PREFIX = '/api'; -const TIMEOUT_MS = 30_000; -// ──────────────────────────────────────────────────────────────────────── - -export class ApiClient { - private readonly base_url: string; - private readonly headers: Record; - private readonly insecure: boolean; - - constructor(conn: ApiConnection) { - this.base_url = conn.baseUrl.replace(/\/+$/, '') + API_PREFIX; - - // ── Customize auth headers ────────────────────────────────── - // Examples: - // Bearer token: { 'Authorization': `Bearer ${conn.apiKey}` } - // API key header: { 'DOLAPIKEY': conn.apiKey } - // Basic auth: { 'Authorization': `Basic ${btoa(user + ':' + pass)}` } - this.headers = { - 'Authorization': `Bearer ${conn.apiKey}`, - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }; - this.insecure = conn.insecure ?? false; - } - - async get(endpoint: string, params?: Record): Promise { - return this.request(this.buildUrl(endpoint, params), 'GET'); - } - - async post(endpoint: string, body?: unknown): Promise { - return this.request(this.buildUrl(endpoint), 'POST', body); - } - - async put(endpoint: string, body: unknown): Promise { - return this.request(this.buildUrl(endpoint), 'PUT', body); - } - - async patch(endpoint: string, body: unknown): Promise { - return this.request(this.buildUrl(endpoint), 'PATCH', body); - } - - async delete(endpoint: string): Promise { - return this.request(this.buildUrl(endpoint), 'DELETE'); - } - - private buildUrl(endpoint: string, params?: Record): string { - const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; - const url = new URL(`${this.base_url}${path}`); - if (params) { - for (const [key, value] of Object.entries(params)) { - url.searchParams.set(key, value); - } - } - return url.toString(); - } - - private request(url: string, method: string, body?: unknown): Promise { - return new Promise((resolve, reject) => { - const parsed = new URL(url); - const is_https = parsed.protocol === 'https:'; - const transport = is_https ? https : http; - - const options: https.RequestOptions = { - hostname: parsed.hostname, - port: parsed.port || (is_https ? 443 : 80), - path: parsed.pathname + parsed.search, - method, - headers: { ...this.headers }, - timeout: TIMEOUT_MS, - }; - - if (this.insecure && is_https) { - options.rejectUnauthorized = false; - } - - const payload = body !== undefined ? JSON.stringify(body) : undefined; - if (payload) { - (options.headers as Record)['Content-Length'] = Buffer.byteLength(payload).toString(); - } - - const req = transport.request(options, (res) => { - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => { - const raw = Buffer.concat(chunks).toString('utf-8'); - let data: unknown; - try { - data = JSON.parse(raw); - } catch { - data = raw; - } - resolve({ status: res.statusCode ?? 0, data }); - }); - }); - - req.on('error', (err) => reject(err)); - req.on('timeout', () => { - req.destroy(); - reject(new Error('Request timed out')); - }); - - if (payload) { - req.write(payload); - } - req.end(); - }); - } -} diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index d3d9c19..0000000 --- a/src/config.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* Copyright (C) 2026 Moko Consulting - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: mcp_windows.Config - * INGROUP: mcp_windows - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mcp_windows - * PATH: /src/config.ts - * VERSION: 01.00.00 - * BRIEF: Configuration loader for Windows Desktop MCP connections - */ - -import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { homedir } from 'node:os'; -import type { ApiConfig, ApiConnection } from './types.js'; - -// ── Customize this ────────────────────────────────────────────────────── -// Change the filename to match your project (e.g. ".dolibarr-api-mcp.json") -const CONFIG_FILENAME = '.mcp_windows.json'; -// Change the env var name to match your project -const CONFIG_ENV_VAR = 'MCP_WINDOWS_CONFIG'; -// ──────────────────────────────────────────────────────────────────────── - -export async function loadConfig(): Promise { - const config_path = process.env[CONFIG_ENV_VAR] - ? resolve(process.env[CONFIG_ENV_VAR]!) - : resolve(homedir(), CONFIG_FILENAME); - - try { - const raw = await readFile(config_path, 'utf-8'); - const parsed = JSON.parse(raw) as Partial; - - if (!parsed.connections || Object.keys(parsed.connections).length === 0) { - throw new Error('No connections defined in config'); - } - - return { - connections: parsed.connections, - defaultConnection: parsed.defaultConnection ?? Object.keys(parsed.connections)[0], - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new Error( - `Failed to load config from ${config_path}: ${message}\n` + - `Create ${config_path} — see config.example.json for format.`, - ); - } -} - -export function getConnection(config: ApiConfig, name?: string): ApiConnection { - const key = name ?? config.defaultConnection; - const conn = config.connections[key]; - if (!conn) { - throw new Error( - `Connection "${key}" not found. Available: ${Object.keys(config.connections).join(', ')}`, - ); - } - return conn; -} diff --git a/src/index.ts b/src/index.ts index 38a3a65..9970c1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,199 +1,85 @@ #!/usr/bin/env node /* Copyright (C) 2026 Moko Consulting - * - * This file is part of a Moko Consulting project. - * * SPDX-License-Identifier: GPL-3.0-or-later * - * FILE INFORMATION - * DEFGROUP: mcp_windows.Server - * INGROUP: mcp_windows - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mcp_windows - * PATH: /src/index.ts - * VERSION: 01.00.00 - * BRIEF: MCP server entry point — registers all Windows Desktop API tools + * mcp_windows — MCP server for Windows desktop system operations */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; -import { loadConfig, getConnection } from './config.js'; -import { ApiClient } from './client.js'; -import type { ApiConfig, ApiResponse } from './types.js'; - -let config: ApiConfig; - -function clientFor(connection?: string): ApiClient { - return new ApiClient(getConnection(config, connection)); -} - -function formatResponse(res: ApiResponse): { content: Array<{ type: 'text'; text: string }> } { - if (res.status >= 400) { - return { - content: [{ type: 'text' as const, text: `Error: HTTP ${res.status}: ${JSON.stringify(res.data, null, 2)}` }], - }; - } - return { - content: [{ type: 'text' as const, text: JSON.stringify(res.data, null, 2) }], - }; -} - -// ── Shared parameter definitions ──────────────────────────────────────── - -const ConnectionParam = { - connection: z.string().optional().describe('Named connection from config (uses default if omitted)'), -}; - -const PaginationParams = { - limit: z.number().optional().describe('Max results'), - page: z.number().optional().describe('Page number (0-based)'), -}; - -function paginationQuery(params: { limit?: number; page?: number }): Record { - const q: Record = {}; - if (params.limit !== undefined) q['limit'] = String(params.limit); - if (params.page !== undefined) q['page'] = String(params.page); - return q; -} - -// ── Server ────────────────────────────────────────────────────────────── +import { registerExecuteTools } from './tools/execute.js'; +import { registerProcessTools } from './tools/process.js'; +import { registerAudioTools } from './tools/audio.js'; +import { registerSystemTools } from './tools/system.js'; +import { registerTerminalTools } from './tools/terminal.js'; +import { registerFilesystemTools } from './tools/filesystem.js'; +import { registerProcessKillTools } from './tools/process_kill.js'; +import { registerServiceTools } from './tools/service.js'; +import { registerAudioAppTools } from './tools/audio_apps.js'; +import { registerPowerTools } from './tools/power.js'; +import { registerNetworkTools } from './tools/network.js'; +import { registerDriveTools } from './tools/drives.js'; +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', version: '1.0.0', }); -// ════════════════════════════════════════════════════════════════════════ -// ADD YOUR TOOLS BELOW -// ════════════════════════════════════════════════════════════════════════ -// -// Follow this pattern for each tool: -// -// server.tool( -// 'prefix_resource_action', // tool name (snake_case) -// 'Human-readable description', // shown to the AI assistant -// { // Zod schema for parameters -// id: z.number().describe('Resource ID'), -// ...ConnectionParam, -// }, -// async ({ id, connection }) => { -// const client = clientFor(connection); -// return formatResponse(await client.get(`/resources/${id}`)); -// }, -// ); -// -// Tips: -// - Group tools by resource type with section comments -// - Use consistent naming: prefix_resource_list, prefix_resource_get, -// prefix_resource_create, prefix_resource_update, prefix_resource_delete -// - Spread ...ConnectionParam into every tool's schema -// - Spread ...PaginationParams into list tools -// - Use paginationQuery() to build query params for list endpoints -// -// ════════════════════════════════════════════════════════════════════════ +// v1.0 — Core +registerExecuteTools(server); +registerProcessTools(server); +registerAudioTools(server); +registerSystemTools(server); +registerTerminalTools(server); +registerFilesystemTools(server); -// ── Example: Resources ────────────────────────────────────────────────── +// v1.1 — System Control +registerProcessKillTools(server); +registerServiceTools(server); +registerAudioAppTools(server); +registerPowerTools(server); +registerNetworkTools(server); +registerDriveTools(server); -server.tool( - 'example_resources_list', - 'List resources (EXAMPLE — replace with your API resources)', - { - search: z.string().optional().describe('Search query'), - ...PaginationParams, - ...ConnectionParam, - }, - async ({ search, limit, page, connection }) => { - const client = clientFor(connection); - const params: Record = { ...paginationQuery({ limit, page }) }; - if (search) params['search'] = search; - return formatResponse(await client.get('/resources', params)); - }, -); +// v1.2 — Desktop Automation +registerDisplayTools(server); +registerWindowTools(server); +registerClipboardTools(server); +registerNotificationTools(server); -server.tool( - 'example_resource_get', - 'Get a single resource by ID (EXAMPLE — replace with your API resources)', - { - id: z.number().describe('Resource ID'), - ...ConnectionParam, - }, - async ({ id, connection }) => { - const client = clientFor(connection); - return formatResponse(await client.get(`/resources/${id}`)); - }, -); +// v1.3 — Admin Tools +registerSchedulerTools(server); +registerRegistryTools(server); +registerEnvironmentTools(server); +registerStartupTools(server); +registerConfigTools(server); -server.tool( - 'example_resource_create', - 'Create a new resource (EXAMPLE — replace with your API resources)', - { - name: z.string().describe('Resource name'), - description: z.string().optional().describe('Resource description'), - ...ConnectionParam, - }, - async ({ name, description, connection }) => { - const client = clientFor(connection); - const body: Record = { name }; - if (description) body.description = description; - return formatResponse(await client.post('/resources', body)); - }, -); - -// ── Generic API Call ──────────────────────────────────────────────────── - -server.tool( - 'api_request', - 'Make a raw API request to any endpoint', - { - method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'), - endpoint: z.string().describe('API endpoint path (e.g. "/resources")'), - body: z.record(z.string(), z.unknown()).optional().describe('Request body for POST/PUT/PATCH'), - params: z.record(z.string(), z.string()).optional().describe('Query parameters'), - ...ConnectionParam, - }, - async ({ method, endpoint, body, params, connection }) => { - const client = clientFor(connection); - switch (method) { - case 'GET': - return formatResponse(await client.get(endpoint, params)); - case 'POST': - return formatResponse(await client.post(endpoint, body)); - case 'PUT': - return formatResponse(await client.put(endpoint, body)); - case 'PATCH': - return formatResponse(await client.patch(endpoint, body)); - case 'DELETE': - return formatResponse(await client.delete(endpoint)); - } - }, -); - -// ── Connections Management ────────────────────────────────────────────── - -server.tool( - 'list_connections', - 'List configured API connections', - {}, - async () => { - const lines = Object.entries(config.connections).map(([name, conn]) => { - const is_default = name === config.defaultConnection ? ' (default)' : ''; - return ` ${name}${is_default}: ${conn.baseUrl}`; - }); - return { - content: [{ type: 'text' as const, text: `Configured connections:\n${lines.join('\n')}` }], - }; - }, -); - -// ── Start Server ──────────────────────────────────────────────────────── +// v1.4 — Advanced +registerAppsTools(server); +registerDialogTools(server); +registerNetstatTools(server); +registerRecycleBinTools(server); async function main(): Promise { - config = await loadConfig(); const transport = new StdioServerTransport(); await server.connect(transport); + process.stderr.write('mcp_windows: server started\n'); } main().catch((err) => { - process.stderr.write(`Fatal: ${err}\n`); + process.stderr.write(`mcp_windows: fatal error: ${err}\n`); process.exit(1); }); diff --git a/src/shell.ts b/src/shell.ts new file mode 100644 index 0000000..5a0062e --- /dev/null +++ b/src/shell.ts @@ -0,0 +1,247 @@ +#!/usr/bin/env node +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { execFile, spawn, ChildProcess } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export interface ShellResult { + stdout: string; + stderr: string; + exitCode: number; +} + +const POWERSHELL = 'powershell.exe'; +const DEFAULT_TIMEOUT = 30_000; + +/** + * Run a PowerShell command and return stdout/stderr/exitCode. + * Commands are wrapped with -NoProfile -NonInteractive for speed and safety. + */ +export async function runPowerShell( + command: string, + options: { timeout?: number; cwd?: string } = {}, +): Promise { + const timeout = options.timeout ?? DEFAULT_TIMEOUT; + try { + const { stdout, stderr } = await execFileAsync(POWERSHELL, [ + '-NoProfile', + '-NonInteractive', + '-Command', + command, + ], { + timeout, + cwd: options.cwd, + maxBuffer: 10 * 1024 * 1024, + windowsHide: true, + }); + return { stdout: stdout.trimEnd(), stderr: stderr.trimEnd(), exitCode: 0 }; + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string; code?: number | string; killed?: boolean }; + if (e.killed) { + return { + stdout: e.stdout?.trimEnd() ?? '', + stderr: `Command timed out after ${timeout}ms`, + exitCode: -1, + }; + } + return { + stdout: e.stdout?.trimEnd() ?? '', + stderr: e.stderr?.trimEnd() ?? String(err), + exitCode: typeof e.code === 'number' ? e.code : 1, + }; + } +} + +/** + * Run a PowerShell command that returns JSON. Wraps output with ConvertTo-Json + * and parses the result. + */ +export async function runPowerShellJson( + command: string, + options: { timeout?: number; cwd?: string } = {}, +): Promise { + const wrapped = `${command} | ConvertTo-Json -Depth 10 -Compress`; + const result = await runPowerShell(wrapped, options); + if (result.exitCode !== 0) { + throw new Error(result.stderr || `PowerShell exited with code ${result.exitCode}`); + } + if (!result.stdout) { + return [] as unknown as T; + } + return JSON.parse(result.stdout) as T; +} + +/** + * Run a generic shell command (cmd, bash, pwsh). + */ +export async function runShell( + command: string, + options: { + shell?: 'pwsh' | 'cmd' | 'bash'; + timeout?: number; + cwd?: string; + } = {}, +): Promise { + const shell = options.shell ?? 'pwsh'; + const timeout = options.timeout ?? DEFAULT_TIMEOUT; + + let executable: string; + let args: string[]; + + switch (shell) { + case 'cmd': + executable = 'cmd.exe'; + args = ['/c', command]; + break; + case 'bash': + executable = 'bash'; + args = ['-c', command]; + break; + case 'pwsh': + default: + executable = POWERSHELL; + args = ['-NoProfile', '-NonInteractive', '-Command', command]; + break; + } + + try { + const { stdout, stderr } = await execFileAsync(executable, args, { + timeout, + cwd: options.cwd, + maxBuffer: 10 * 1024 * 1024, + windowsHide: true, + }); + return { stdout: stdout.trimEnd(), stderr: stderr.trimEnd(), exitCode: 0 }; + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string; code?: number | string; killed?: boolean }; + if (e.killed) { + return { + stdout: e.stdout?.trimEnd() ?? '', + stderr: `Command timed out after ${timeout}ms`, + exitCode: -1, + }; + } + return { + stdout: e.stdout?.trimEnd() ?? '', + stderr: e.stderr?.trimEnd() ?? String(err), + exitCode: typeof e.code === 'number' ? e.code : 1, + }; + } +} + + +// ── Persistent Terminal Sessions ───────────────────────────────────────── + +export interface TerminalSession { + pid: number; + shell: string; + process: ChildProcess; + output: string[]; + startedAt: Date; +} + +const sessions = new Map(); +const MAX_OUTPUT_LINES = 5000; + +export function startSession(shell: 'pwsh' | 'cmd' | 'bash' | 'python' | 'node' | 'wsl' = 'pwsh'): TerminalSession { + let executable: string; + let args: string[]; + + switch (shell) { + case 'cmd': + executable = 'cmd.exe'; + args = []; + break; + case 'bash': + executable = 'bash'; + args = []; + break; + case 'python': + executable = 'python'; + args = ['-i']; + break; + case 'node': + executable = 'node'; + args = []; + break; + case 'wsl': + executable = 'wsl.exe'; + args = []; + break; + case 'pwsh': + default: + executable = POWERSHELL; + args = ['-NoProfile', '-NoLogo']; + break; + } + + const proc = spawn(executable, args, { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + + const session: TerminalSession = { + pid: proc.pid!, + shell, + process: proc, + output: [], + startedAt: new Date(), + }; + + const pushLine = (line: string) => { + session.output.push(line); + if (session.output.length > MAX_OUTPUT_LINES) { + session.output.splice(0, session.output.length - MAX_OUTPUT_LINES); + } + }; + + proc.stdout?.on('data', (data: Buffer) => { + data.toString().split('\n').forEach(pushLine); + }); + proc.stderr?.on('data', (data: Buffer) => { + data.toString().split('\n').forEach(l => pushLine(`[stderr] ${l}`)); + }); + proc.on('exit', () => { + pushLine(`[session ended]`); + }); + + sessions.set(proc.pid!, session); + return session; +} + +export function sendToSession(pid: number, input: string): void { + const session = sessions.get(pid); + if (!session) throw new Error(`No session with PID ${pid}`); + if (session.process.exitCode !== null) throw new Error(`Session ${pid} has ended`); + session.process.stdin?.write(input + '\n'); +} + +export function readSessionOutput(pid: number, offset = 0, length?: number): string[] { + const session = sessions.get(pid); + if (!session) throw new Error(`No session with PID ${pid}`); + const start = offset < 0 ? Math.max(0, session.output.length + offset) : offset; + const end = length !== undefined ? start + length : undefined; + return session.output.slice(start, end); +} + +export function listSessions(): Array<{ pid: number; shell: string; running: boolean; lines: number; startedAt: string }> { + return Array.from(sessions.values()).map(s => ({ + pid: s.pid, + shell: s.shell, + running: s.process.exitCode === null, + lines: s.output.length, + startedAt: s.startedAt.toISOString(), + })); +} + +export function terminateSession(pid: number): boolean { + const session = sessions.get(pid); + if (!session) return false; + session.process.kill(); + sessions.delete(pid); + return true; +} diff --git a/src/tools/apps.ts b/src/tools/apps.ts new file mode 100644 index 0000000..e70b240 --- /dev/null +++ b/src/tools/apps.ts @@ -0,0 +1,76 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_installed_apps (#19) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerAppsTools(server: McpServer): void { + server.tool( + 'windows_installed_apps', + 'List installed applications from registry and Microsoft Store.', + { + filter: z.string().optional().describe('Filter by app name (substring)'), + sort: z.enum(['name', 'date', 'size']).default('name').describe('Sort order'), + limit: z.number().default(50).describe('Max results'), + }, + async ({ filter, sort, limit }) => { + const filterClause = filter + ? `| Where-Object { $_.DisplayName -like '*${filter.replace(/'/g, "''")}*' }` + : ''; + + const sortClause = sort === 'date' + ? '| Sort-Object InstallDate -Descending' + : sort === 'size' + ? '| Sort-Object { [int]$_.EstimatedSize } -Descending' + : '| Sort-Object DisplayName'; + + const ps = ` +$regPaths = @( + 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*', + 'HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*', + 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' +) + +$apps = $regPaths | ForEach-Object { + Get-ItemProperty -Path $_ -ErrorAction SilentlyContinue +} | Where-Object { $_.DisplayName } ${filterClause} ${sortClause} | + Select-Object -First ${limit} | + ForEach-Object { + [PSCustomObject]@{ + Name = $_.DisplayName + Version = $_.DisplayVersion + Publisher = $_.Publisher + InstallDate = $_.InstallDate + SizeMB = if ($_.EstimatedSize) { [math]::Round($_.EstimatedSize / 1024, 1) } else { $null } + } + } + +$apps | ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps, { timeout: 20000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + if (!result.stdout) { + return { content: [{ type: 'text', text: 'No apps found.' }] }; + } + + const apps = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = apps.map((a: { Name: string; Version: string; Publisher: string; InstallDate: string; SizeMB: number | null }) => { + const size = a.SizeMB ? `${String(a.SizeMB).padStart(8)} MB` : ' '; + const date = a.InstallDate || ' '; + return `${(a.Name || '').padEnd(45).slice(0, 45)} ${(a.Version || '').padEnd(15).slice(0, 15)} ${date.padEnd(10)} ${size} ${(a.Publisher || '').slice(0, 25)}`; + }); + + const header = `${'Name'.padEnd(45)} ${'Version'.padEnd(15)} ${'Installed'.padEnd(10)} ${'Size'.padStart(11)} Publisher`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(115)}\n${lines.join('\n')}\n\n${apps.length} applications` }], + }; + }, + ); +} diff --git a/src/tools/audio.ts b/src/tools/audio.ts new file mode 100644 index 0000000..07e2a52 --- /dev/null +++ b/src/tools/audio.ts @@ -0,0 +1,161 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_audio_get (#6), windows_audio_set (#7) + * Uses compiled SetMute.exe for reliable COM audio control. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +const AUDIO_PS = ` +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +public class WinAudio { + [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IMMDeviceEnumerator { + int EnumAudioEndpoints(int dataFlow, int dwStateMask, out IntPtr ppDevices); + int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice ppEndpoint); + } + + [Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IMMDevice { + int Activate(ref Guid iid, int dwClsCtx, IntPtr pActivationParams, [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface); + } + + [Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IAudioEndpointVolume { + int RegisterControlChangeNotify(IntPtr pNotify); + int UnregisterControlChangeNotify(IntPtr pNotify); + int GetChannelCount(out int pnChannelCount); + int SetMasterVolumeLevel(float fLevelDB, ref Guid pguidEventContext); + int SetMasterVolumeLevelScalar(float fLevel, ref Guid pguidEventContext); + int GetMasterVolumeLevel(out float pfLevelDB); + int GetMasterVolumeLevelScalar(out float pfLevel); + int SetChannelVolumeLevel(int nChannel, float fLevelDB, ref Guid pguidEventContext); + int SetChannelVolumeLevelScalar(int nChannel, float fLevel, ref Guid pguidEventContext); + int GetChannelVolumeLevel(int nChannel, out float pfLevelDB); + int GetChannelVolumeLevelScalar(int nChannel, out float pfLevel); + int SetMute([MarshalAs(UnmanagedType.Bool)] bool bMute, ref Guid pguidEventContext); + int GetMute([MarshalAs(UnmanagedType.Bool)] out bool pbMute); + } + + private static IAudioEndpointVolume GetVolume() { + var type = Type.GetTypeFromCLSID(new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")); + var enumerator = (IMMDeviceEnumerator)Activator.CreateInstance(type); + IMMDevice device; + enumerator.GetDefaultAudioEndpoint(0, 1, out device); + var iid = new Guid("5CDF2C82-841E-4546-9722-0CF74078229A"); + object obj; + device.Activate(ref iid, 0x17, IntPtr.Zero, out obj); + return (IAudioEndpointVolume)obj; + } + + public static float GetVolumeLevel() { + var vol = GetVolume(); + float level; + vol.GetMasterVolumeLevelScalar(out level); + return level; + } + + public static bool GetMute() { + var vol = GetVolume(); + bool muted; + vol.GetMute(out muted); + return muted; + } + + public static void SetVolumeLevel(float level) { + var vol = GetVolume(); + var ctx = Guid.Empty; + vol.SetMasterVolumeLevelScalar(level, ref ctx); + } + + public static void SetMute(bool mute) { + var vol = GetVolume(); + var ctx = Guid.Empty; + vol.SetMute(mute, ref ctx); + } +} +'@ -ErrorAction Stop +`; + +export function registerAudioTools(server: McpServer): void { + server.tool( + 'windows_audio_get', + 'Get current audio state: volume level (0-100), mute status, and default playback device.', + {}, + async () => { + const ps = ` +${AUDIO_PS} +$volume = [math]::Round([WinAudio]::GetVolumeLevel() * 100) +$muted = [WinAudio]::GetMute() +$device = (Get-CimInstance Win32_SoundDevice | Where-Object { $_.Status -eq 'OK' } | Select-Object -First 1).Name +[PSCustomObject]@{ + volume = $volume + muted = $muted + device = $device +} | ConvertTo-Json -Compress`; + + const result = await runPowerShell(ps); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + const state = JSON.parse(result.stdout); + const muteIcon = state.muted ? '🔇' : (state.volume > 50 ? '🔊' : '🔉'); + return { + content: [{ + type: 'text', + text: `${muteIcon} Volume: ${state.volume}%${state.muted ? ' (MUTED)' : ''}\nDevice: ${state.device || 'Unknown'}`, + }], + }; + }, + ); + + server.tool( + 'windows_audio_set', + 'Set audio volume (0-100), mute/unmute, or toggle mute.', + { + volume: z.number().min(0).max(100).optional().describe('Volume level 0-100'), + mute: z.enum(['true', 'false', 'toggle']).optional().describe('Mute state: true, false, or toggle'), + }, + async ({ volume, mute }) => { + const commands: string[] = [AUDIO_PS]; + + if (volume !== undefined) { + commands.push(`[WinAudio]::SetVolumeLevel(${volume / 100})`); + } + + if (mute === 'toggle') { + commands.push(`[WinAudio]::SetMute(-not [WinAudio]::GetMute())`); + } else if (mute === 'true') { + commands.push(`[WinAudio]::SetMute($true)`); + } else if (mute === 'false') { + commands.push(`[WinAudio]::SetMute($false)`); + } + + // Read back state + commands.push(` +$vol = [math]::Round([WinAudio]::GetVolumeLevel() * 100) +$m = [WinAudio]::GetMute() +[PSCustomObject]@{ volume = $vol; muted = $m } | ConvertTo-Json -Compress`); + + const result = await runPowerShell(commands.join('\n')); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + const state = JSON.parse(result.stdout); + return { + content: [{ + type: 'text', + text: `Volume set to ${state.volume}%${state.muted ? ' (MUTED)' : ''}`, + }], + }; + }, + ); +} diff --git a/src/tools/audio_apps.ts b/src/tools/audio_apps.ts new file mode 100644 index 0000000..b449f32 --- /dev/null +++ b/src/tools/audio_apps.ts @@ -0,0 +1,98 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_audio_app_volumes (#8) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerAudioAppTools(server: McpServer): void { + server.tool( + 'windows_audio_app_volumes', + 'Get or set per-application audio volume levels. Without set params, lists all app audio sessions.', + { + app: z.string().optional().describe('App name to target (for set operations)'), + volume: z.number().min(0).max(100).optional().describe('Volume to set (0-100)'), + mute: z.enum(['true', 'false', 'toggle']).optional().describe('Mute state to set'), + }, + async ({ app, volume, mute }) => { + // List mode — show all audio sessions via PowerShell + COM + const ps = ` +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Collections.Generic; +using System.Diagnostics; + +public class AudioSessions { + [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IMMDeviceEnumerator { + int EnumAudioEndpoints(int dataFlow, int dwStateMask, out IntPtr ppDevices); + int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice ppEndpoint); + } + + [Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IMMDevice { + int Activate(ref Guid iid, int dwClsCtx, IntPtr pActivationParams, [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface); + } + + [Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IAudioSessionManager2 { + int _0(); // QueryInterface stuff + int _1(); + int GetSessionEnumerator(out IAudioSessionEnumerator ppEnum); + } + + [Guid("E2F5BB11-0570-40CA-ACDD-3AA01277DEE8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + interface IAudioSessionEnumerator { + int GetCount(out int count); + int GetSession(int index, [MarshalAs(UnmanagedType.IUnknown)] out object ppSession); + } + + public static string GetSessions() { + // For now, use a simpler approach via Get-Process + return "use_powershell"; + } +} +'@ -ErrorAction SilentlyContinue + +# Use the Volume Mixer approach via Get-Process with audio +$sessions = Get-Process | Where-Object { $_.MainWindowTitle -ne '' -or $_.ProcessName -match 'chrome|firefox|spotify|vlc|teams|discord|zoom|music|video|media' } | + Select-Object Id, ProcessName, MainWindowTitle | + Sort-Object ProcessName -Unique + +$sessions | ForEach-Object { + [PSCustomObject]@{ + PID = $_.Id + Name = $_.ProcessName + Title = $_.MainWindowTitle + } +} | 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 (!app && volume === undefined && !mute) { + // List mode + if (!result.stdout) { + return { content: [{ type: 'text', text: 'No audio app sessions detected.' }] }; + } + const apps = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = apps.map((a: { PID: number; Name: string; Title: string }) => + `PID ${String(a.PID).padStart(6)} ${a.Name.padEnd(25)} ${a.Title || '(no window)'}`, + ); + return { + content: [{ type: 'text', text: `Audio-capable processes:\n\n${lines.join('\n')}\n\nNote: Per-app volume control requires the SndVol COM API. Use windows_audio_set for master volume.` }], + }; + } + + return { + content: [{ type: 'text', text: `Per-app volume set/mute requires elevated SndVol COM access. Use windows_execute with PowerShell to control specific app audio, or use windows_audio_set for master volume.` }], + }; + }, + ); +} diff --git a/src/tools/clipboard.ts b/src/tools/clipboard.ts new file mode 100644 index 0000000..05e3a80 --- /dev/null +++ b/src/tools/clipboard.ts @@ -0,0 +1,138 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_clipboard_get (#16), windows_clipboard_set (#17) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerClipboardTools(server: McpServer): void { + server.tool( + 'windows_clipboard_get', + 'Read clipboard contents: text, file list, or image (returned as base64).', + {}, + async () => { + // PowerShell must run in STA mode for clipboard access + const ps = ` +powershell.exe -NoProfile -STA -Command { + Add-Type -AssemblyName System.Windows.Forms + $data = [System.Windows.Forms.Clipboard]::GetDataObject() + if (-not $data) { Write-Output '{"type":"empty","content":"Clipboard is empty"}'; return } + + # Check for files + if ($data.ContainsFileDropList()) { + $files = [System.Windows.Forms.Clipboard]::GetFileDropList() + $list = @() + foreach ($f in $files) { $list += $f } + $json = @{ type = 'files'; content = $list } | ConvertTo-Json -Compress + Write-Output $json + return + } + + # Check for image + if ($data.ContainsImage()) { + $img = [System.Windows.Forms.Clipboard]::GetImage() + $ms = New-Object System.IO.MemoryStream + $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) + $b64 = [Convert]::ToBase64String($ms.ToArray()) + $ms.Dispose() + $img.Dispose() + $json = @{ type = 'image'; content = $b64 } | ConvertTo-Json -Compress + Write-Output $json + return + } + + # Text + if ($data.ContainsText()) { + $text = [System.Windows.Forms.Clipboard]::GetText() + $json = @{ type = 'text'; content = $text } | ConvertTo-Json -Compress + Write-Output $json + return + } + + Write-Output '{"type":"unknown","content":"Clipboard contains unsupported format"}' +}`; + + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + try { + const data = JSON.parse(result.stdout); + + if (data.type === 'image') { + return { + content: [ + { type: 'text' as const, text: 'Clipboard contains an image:' }, + { type: 'image' as const, data: data.content, mimeType: 'image/png' }, + ], + }; + } + + if (data.type === 'files') { + const files = Array.isArray(data.content) ? data.content : [data.content]; + return { + content: [{ type: 'text', text: `Clipboard contains ${files.length} file(s):\n${files.join('\n')}` }], + }; + } + + return { + content: [{ type: 'text', text: data.content || '(empty)' }], + }; + } catch { + return { content: [{ type: 'text', text: result.stdout || '(empty clipboard)' }] }; + } + }, + ); + + server.tool( + 'windows_clipboard_set', + 'Set clipboard contents: text, file list, or clear.', + { + text: z.string().optional().describe('Text to copy to clipboard'), + files: z.array(z.string()).optional().describe('File paths to copy to clipboard'), + clear: z.boolean().optional().describe('Clear the clipboard'), + }, + async ({ text, files, clear }) => { + if (clear) { + const result = await runPowerShell(` +powershell.exe -NoProfile -STA -Command { + Add-Type -AssemblyName System.Windows.Forms + [System.Windows.Forms.Clipboard]::Clear() + "Clipboard cleared" +}`); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + + if (text) { + // Escape for PowerShell here-string + const escaped = text.replace(/'/g, "''"); + const result = await runPowerShell(` +powershell.exe -NoProfile -STA -Command { + Add-Type -AssemblyName System.Windows.Forms + [System.Windows.Forms.Clipboard]::SetText('${escaped}') + "Copied $([System.Windows.Forms.Clipboard]::GetText().Length) chars to clipboard" +}`); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + + if (files && files.length > 0) { + const pathList = files.map(f => `$fc.Add('${f.replace(/'/g, "''")}')`).join('; '); + const result = await runPowerShell(` +powershell.exe -NoProfile -STA -Command { + Add-Type -AssemblyName System.Windows.Forms + $fc = New-Object System.Collections.Specialized.StringCollection + ${pathList} + [System.Windows.Forms.Clipboard]::SetFileDropList($fc) + "Copied $($fc.Count) file(s) to clipboard" +}`); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + + return { content: [{ type: 'text', text: 'Provide text, files, or clear.' }], isError: true }; + }, + ); +} diff --git a/src/tools/config.ts b/src/tools/config.ts new file mode 100644 index 0000000..0f96637 --- /dev/null +++ b/src/tools/config.ts @@ -0,0 +1,105 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_mcp_config (#40) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { homedir } from 'node:os'; + +const CONFIG_PATH = resolve(homedir(), '.mcp_windows.json'); + +interface McpConfig { + blockedCommands: string[]; + allowedDirectories: string[]; + outputLineLimit: number; + commandTimeout: number; +} + +const DEFAULT_CONFIG: McpConfig = { + blockedCommands: ['Format-Volume', 'Clear-Disk', 'Remove-Partition'], + allowedDirectories: [], + outputLineLimit: 5000, + commandTimeout: 30000, +}; + +let currentConfig: McpConfig | null = null; + +async function loadConfig(): Promise { + if (currentConfig) return currentConfig; + try { + const raw = await readFile(CONFIG_PATH, 'utf-8'); + currentConfig = { ...DEFAULT_CONFIG, ...JSON.parse(raw) }; + } catch { + currentConfig = { ...DEFAULT_CONFIG }; + } + return currentConfig!; +} + +async function saveConfig(config: McpConfig): Promise { + await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8'); + currentConfig = config; +} + +export function registerConfigTools(server: McpServer): void { + server.tool( + 'windows_mcp_config', + 'Get or set mcp_windows configuration (blocked commands, allowed directories, output limits).', + { + action: z.enum(['get', 'set']).default('get').describe('Get or set config'), + key: z.string().optional().describe('Config key to set (blockedCommands, allowedDirectories, outputLineLimit, commandTimeout)'), + value: z.string().optional().describe('Value to set (JSON for arrays, number for limits)'), + }, + async ({ action, key, value }) => { + const config = await loadConfig(); + + if (action === 'get') { + return { + content: [{ + type: 'text', + text: [ + `mcp_windows configuration (${CONFIG_PATH}):`, + ``, + `Blocked commands: ${config.blockedCommands.join(', ') || '(none)'}`, + `Allowed directories: ${config.allowedDirectories.length > 0 ? config.allowedDirectories.join(', ') : '(unrestricted)'}`, + `Output line limit: ${config.outputLineLimit}`, + `Command timeout: ${config.commandTimeout}ms`, + ].join('\n'), + }], + }; + } + + if (!key || value === undefined) { + return { content: [{ type: 'text', text: 'Set requires key and value.' }], isError: true }; + } + + switch (key) { + case 'blockedCommands': + case 'allowedDirectories': + try { + (config as unknown as Record)[key] = JSON.parse(value); + } catch { + return { content: [{ type: 'text', text: `Value must be a JSON array (e.g. ["cmd1","cmd2"])` }], isError: true }; + } + break; + case 'outputLineLimit': + case 'commandTimeout': { + const num = Number(value); + if (isNaN(num) || num < 0) { + return { content: [{ type: 'text', text: 'Value must be a positive number.' }], isError: true }; + } + (config as unknown as Record)[key] = num; + break; + } + default: + return { content: [{ type: 'text', text: `Unknown key: ${key}. Valid: blockedCommands, allowedDirectories, outputLineLimit, commandTimeout` }], isError: true }; + } + + await saveConfig(config); + return { content: [{ type: 'text', text: `Set ${key} = ${value}` }] }; + }, + ); +} diff --git a/src/tools/dialog.ts b/src/tools/dialog.ts new file mode 100644 index 0000000..16345a6 --- /dev/null +++ b/src/tools/dialog.ts @@ -0,0 +1,87 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_dialog (#21) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerDialogTools(server: McpServer): void { + server.tool( + 'windows_dialog', + 'Show system dialogs: message box, input prompt, file/folder picker.', + { + type: z.enum(['message', 'input', 'file_open', 'file_save', 'folder']).describe('Dialog type'), + title: z.string().default('mcp_windows').describe('Dialog title'), + message: z.string().optional().describe('Message text (for message/input)'), + buttons: z.enum(['ok', 'okcancel', 'yesno', 'yesnocancel']).default('ok').describe('Buttons (for message)'), + filter: z.string().optional().describe('File filter (for file dialogs, e.g. "Text files|*.txt|All files|*.*")'), + default_path: z.string().optional().describe('Default path/filename'), + }, + async ({ type, title, message, buttons, filter, default_path }) => { + let ps: string; + + switch (type) { + case 'message': { + const btnMap: Record = { ok: 0, okcancel: 1, yesno: 4, yesnocancel: 3 }; + ps = ` +Add-Type -AssemblyName System.Windows.Forms +$result = [System.Windows.Forms.MessageBox]::Show('${(message || '').replace(/'/g, "''")}', '${title.replace(/'/g, "''")}', ${btnMap[buttons]}) +$result.ToString()`; + break; + } + + case 'input': + ps = ` +Add-Type -AssemblyName Microsoft.VisualBasic +$result = [Microsoft.VisualBasic.Interaction]::InputBox('${(message || 'Enter value:').replace(/'/g, "''")}', '${title.replace(/'/g, "''")}', '${(default_path || '').replace(/'/g, "''")}') +if ($result) { $result } else { '__CANCELLED__' }`; + break; + + case 'file_open': + ps = ` +Add-Type -AssemblyName System.Windows.Forms +$dlg = New-Object System.Windows.Forms.OpenFileDialog +$dlg.Title = '${title.replace(/'/g, "''")}' +${filter ? `$dlg.Filter = '${filter.replace(/'/g, "''")}'` : ''} +${default_path ? `$dlg.InitialDirectory = '${default_path.replace(/'/g, "''")}'` : ''} +$dlg.Multiselect = $false +if ($dlg.ShowDialog() -eq 'OK') { $dlg.FileName } else { '__CANCELLED__' }`; + break; + + case 'file_save': + ps = ` +Add-Type -AssemblyName System.Windows.Forms +$dlg = New-Object System.Windows.Forms.SaveFileDialog +$dlg.Title = '${title.replace(/'/g, "''")}' +${filter ? `$dlg.Filter = '${filter.replace(/'/g, "''")}'` : ''} +${default_path ? `$dlg.FileName = '${default_path.replace(/'/g, "''")}'` : ''} +if ($dlg.ShowDialog() -eq 'OK') { $dlg.FileName } else { '__CANCELLED__' }`; + break; + + case 'folder': + ps = ` +Add-Type -AssemblyName System.Windows.Forms +$dlg = New-Object System.Windows.Forms.FolderBrowserDialog +$dlg.Description = '${(message || title).replace(/'/g, "''")}' +${default_path ? `$dlg.SelectedPath = '${default_path.replace(/'/g, "''")}'` : ''} +if ($dlg.ShowDialog() -eq 'OK') { $dlg.SelectedPath } else { '__CANCELLED__' }`; + break; + } + + const result = await runPowerShell(ps, { timeout: 120000 }); // Long timeout — user interaction + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + const output = result.stdout.trim(); + if (output === '__CANCELLED__') { + return { content: [{ type: 'text', text: 'Dialog cancelled by user.' }] }; + } + + return { content: [{ type: 'text', text: output }] }; + }, + ); +} diff --git a/src/tools/display.ts b/src/tools/display.ts new file mode 100644 index 0000000..4f5c3ac --- /dev/null +++ b/src/tools/display.ts @@ -0,0 +1,285 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_display_get (#9), windows_display_set (#10), windows_screenshot (#11) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { tmpdir } from 'node:os'; + +export function registerDisplayTools(server: McpServer): void { + server.tool( + 'windows_display_get', + 'Get display configuration: resolution, refresh rate, scaling, multi-monitor layout, HDR status.', + {}, + async () => { + const ps = ` +Add-Type -AssemblyName System.Windows.Forms + +$screens = [System.Windows.Forms.Screen]::AllScreens +$monitors = Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams -ErrorAction SilentlyContinue +$videoCtrl = Get-CimInstance Win32_VideoController + +$i = 0 +$screens | ForEach-Object { + $s = $_ + $vc = $videoCtrl | Where-Object { $_.Name -match $s.DeviceName -or $true } | Select-Object -First 1 + $dpiScale = [math]::Round(($s.Bounds.Width / $s.WorkingArea.Width) * 100, 0) + + # Try to get real scaling from registry + $regScale = $null + try { + $regPath = "HKCU:\\Control Panel\\Desktop\\PerMonitorSettings" + if (Test-Path $regPath) { + $regScale = (Get-ChildItem $regPath -ErrorAction SilentlyContinue | Select-Object -Index $i | Get-ItemProperty -ErrorAction SilentlyContinue).DpiValue + } + } catch {} + + [PSCustomObject]@{ + Index = $i + Name = $s.DeviceName + Primary = $s.Primary + Resolution = "$($s.Bounds.Width)x$($s.Bounds.Height)" + RefreshRate = if ($vc) { "$($vc.CurrentRefreshRate) Hz" } else { 'Unknown' } + Scaling = if ($regScale) { "$($regScale)%" } else { 'System default' } + Position = "($($s.Bounds.X), $($s.Bounds.Y))" + WorkArea = "$($s.WorkingArea.Width)x$($s.WorkingArea.Height)" + BitsPerPixel = if ($vc) { $vc.CurrentBitsPerPixel } else { $null } + } + $i++ +} | 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 displays = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = displays.map((d: { Index: number; Name: string; Primary: boolean; Resolution: string; RefreshRate: string; Scaling: string; Position: string; WorkArea: string }) => + [ + `Monitor ${d.Index}: ${d.Name}${d.Primary ? ' (Primary)' : ''}`, + ` Resolution: ${d.Resolution} @ ${d.RefreshRate}`, + ` Scaling: ${d.Scaling}`, + ` Position: ${d.Position}`, + ` Work area: ${d.WorkArea}`, + ].join('\n'), + ); + + return { content: [{ type: 'text', text: lines.join('\n\n') }] }; + }, + ); + + server.tool( + 'windows_display_set', + 'Change display settings: resolution, brightness.', + { + resolution: z.string().optional().describe('Resolution as "WIDTHxHEIGHT" (e.g. "1920x1080")'), + brightness: z.number().min(0).max(100).optional().describe('Screen brightness 0-100 (laptops only)'), + }, + async ({ resolution, brightness }) => { + const results: string[] = []; + + if (brightness !== undefined) { + const ps = ` +try { + $monitors = Get-CimInstance -Namespace root/WMI -ClassName WmiMonitorBrightnessMethods -ErrorAction Stop + $monitors | Invoke-CimMethod -MethodName WmiSetBrightness -Arguments @{Timeout=1; Brightness=${brightness}} -ErrorAction Stop + "Brightness set to ${brightness}%" +} catch { + "Error: Brightness control not available (requires laptop/integrated display). $_" +}`; + const r = await runPowerShell(ps); + results.push(r.stdout || r.stderr); + } + + if (resolution) { + const match = resolution.match(/^(\d+)x(\d+)$/); + if (!match) { + results.push('Invalid resolution format. Use WIDTHxHEIGHT (e.g. 1920x1080)'); + } else { + const ps = ` +Add-Type @' +using System; +using System.Runtime.InteropServices; + +public class DisplaySettings { + [DllImport("user32.dll")] + public static extern int EnumDisplaySettings(string deviceName, int modeNum, ref DEVMODE devMode); + [DllImport("user32.dll")] + public static extern int ChangeDisplaySettings(ref DEVMODE devMode, int flags); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] + public struct DEVMODE { + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string dmDeviceName; + public short dmSpecVersion; + public short dmDriverVersion; + public short dmSize; + public short dmDriverExtra; + public int dmFields; + public int dmPositionX; + public int dmPositionY; + public int dmDisplayOrientation; + public int dmDisplayFixedOutput; + public short dmColor; + public short dmDuplex; + public short dmYResolution; + public short dmTTOption; + public short dmCollate; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string dmFormName; + public short dmLogPixels; + public int dmBitsPerPel; + public int dmPelsWidth; + public int dmPelsHeight; + public int dmDisplayFlags; + public int dmDisplayFrequency; + } + + public static string SetResolution(int width, int height) { + DEVMODE dm = new DEVMODE(); + dm.dmSize = (short)Marshal.SizeOf(typeof(DEVMODE)); + if (EnumDisplaySettings(null, -1, ref dm) != 0) { + dm.dmPelsWidth = width; + dm.dmPelsHeight = height; + dm.dmFields = 0x80000 | 0x100000; // DM_PELSWIDTH | DM_PELSHEIGHT + int result = ChangeDisplaySettings(ref dm, 0); + if (result == 0) return "Resolution changed to " + width + "x" + height; + return "Failed to change resolution (code: " + result + "). Resolution may not be supported."; + } + return "Failed to enumerate display settings."; + } +} +'@ -ErrorAction Stop +[DisplaySettings]::SetResolution(${match[1]}, ${match[2]})`; + const r = await runPowerShell(ps); + results.push(r.stdout || r.stderr); + } + } + + if (results.length === 0) { + return { content: [{ type: 'text', text: 'No changes specified. Provide resolution or brightness.' }], isError: true }; + } + + return { content: [{ type: 'text', text: results.join('\n') }] }; + }, + ); + + server.tool( + 'windows_screenshot', + 'Capture a screenshot of the screen, a specific window, or a region. Returns as base64 image.', + { + target: z.enum(['screen', 'window', 'region']).default('screen').describe('What to capture'), + monitor: z.number().default(0).describe('Monitor index (for screen capture)'), + window_title: z.string().optional().describe('Window title substring (for window capture)'), + x: z.number().optional().describe('Region X (for region capture)'), + y: z.number().optional().describe('Region Y'), + width: z.number().optional().describe('Region width'), + height: z.number().optional().describe('Region height'), + save_path: z.string().optional().describe('Save to file instead of returning base64'), + }, + async ({ target, monitor, window_title, x, y, width, height, save_path }) => { + const outPath = save_path ? resolve(save_path) : resolve(tmpdir(), `screenshot_${Date.now()}.png`); + + let ps: string; + + if (target === 'window' && window_title) { + ps = ` +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +Add-Type @' +using System; +using System.Runtime.InteropServices; +public class Win32Window { + [DllImport("user32.dll")] public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + [StructLayout(LayoutKind.Sequential)] + public struct RECT { public int Left, Top, Right, Bottom; } +} +'@ +$target = '${window_title.replace(/'/g, "''")}' +$found = $null +[Win32Window]::EnumWindows({ + param($hWnd, $lParam) + $sb = New-Object System.Text.StringBuilder 256 + [Win32Window]::GetWindowText($hWnd, $sb, 256) | Out-Null + $title = $sb.ToString() + if ($title -like "*$target*") { $script:found = $hWnd; return $false } + return $true +}, [IntPtr]::Zero) | Out-Null + +if (-not $found) { throw "Window not found: $target" } + +$rect = New-Object Win32Window+RECT +[Win32Window]::GetWindowRect($found, [ref]$rect) | Out-Null +$w = $rect.Right - $rect.Left +$h = $rect.Bottom - $rect.Top +$bmp = New-Object System.Drawing.Bitmap($w, $h) +$g = [System.Drawing.Graphics]::FromImage($bmp) +$g.CopyFromScreen($rect.Left, $rect.Top, 0, 0, [System.Drawing.Size]::new($w, $h)) +$g.Dispose() +$bmp.Save('${outPath.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png) +$bmp.Dispose() +"saved"`; + } else if (target === 'region' && x !== undefined && y !== undefined && width && height) { + ps = ` +Add-Type -AssemblyName System.Drawing +$bmp = New-Object System.Drawing.Bitmap(${width}, ${height}) +$g = [System.Drawing.Graphics]::FromImage($bmp) +$g.CopyFromScreen(${x}, ${y}, 0, 0, [System.Drawing.Size]::new(${width}, ${height})) +$g.Dispose() +$bmp.Save('${outPath.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png) +$bmp.Dispose() +"saved"`; + } else { + // Full screen + ps = ` +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +$screen = [System.Windows.Forms.Screen]::AllScreens[${monitor}] +$bounds = $screen.Bounds +$bmp = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height) +$g = [System.Drawing.Graphics]::FromImage($bmp) +$g.CopyFromScreen($bounds.X, $bounds.Y, 0, 0, $bounds.Size) +$g.Dispose() +$bmp.Save('${outPath.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png) +$bmp.Dispose() +"saved"`; + } + + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + if (save_path) { + return { content: [{ type: 'text', text: `Screenshot saved: ${outPath}` }] }; + } + + // Return as base64 image + try { + const data = await readFile(outPath); + // Clean up temp file + await import('node:fs/promises').then(fs => fs.unlink(outPath)).catch(() => {}); + return { + content: [{ + type: 'image' as const, + data: data.toString('base64'), + mimeType: 'image/png', + }], + }; + } catch { + return { content: [{ type: 'text', text: `Screenshot captured at ${outPath} but failed to read back.` }] }; + } + }, + ); +} diff --git a/src/tools/drives.ts b/src/tools/drives.ts new file mode 100644 index 0000000..68f6195 --- /dev/null +++ b/src/tools/drives.ts @@ -0,0 +1,124 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_drives (#24), windows_file_search (#25) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerDriveTools(server: McpServer): void { + server.tool( + 'windows_drives', + 'List all drives/volumes with type, label, capacity, free space, and usage percentage.', + {}, + async () => { + const ps = ` +Get-CimInstance Win32_LogicalDisk | ForEach-Object { + $typeMap = @{0='Unknown';1='No Root';2='Removable';3='Local';4='Network';5='CD/DVD';6='RAM Disk'} + [PSCustomObject]@{ + Drive = $_.DeviceID + Label = $_.VolumeName + Type = $typeMap[[int]$_.DriveType] + FileSystem = $_.FileSystem + TotalGB = if ($_.Size) { [math]::Round($_.Size / 1GB, 1) } else { 0 } + FreeGB = if ($_.FreeSpace) { [math]::Round($_.FreeSpace / 1GB, 1) } else { 0 } + UsedPct = if ($_.Size -gt 0) { [math]::Round(($_.Size - $_.FreeSpace) / $_.Size * 100, 1) } else { 0 } + } +} | 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 drives = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + + const lines = drives.map((d: { Drive: string; Label: string; Type: string; FileSystem: string; TotalGB: number; FreeGB: number; UsedPct: number }) => { + const bar = d.TotalGB > 0 ? makeBar(d.UsedPct) : ' '; + return `${d.Drive} ${(d.Label || '').padEnd(15).slice(0, 15)} ${d.Type.padEnd(10)} ${d.FileSystem?.padEnd(5) || ' '} ${String(d.FreeGB).padStart(8)}/${String(d.TotalGB).padStart(8)} GB ${bar} ${d.UsedPct}%`; + }); + + const header = `Drv ${'Label'.padEnd(15)} ${'Type'.padEnd(10)} FS ${'Free'.padStart(8)}/${'Total'.padStart(8)} Usage`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(90)}\n${lines.join('\n')}` }], + }; + }, + ); + + server.tool( + 'windows_file_search', + 'Search for files using Windows Search index (fast) or filesystem walk (fallback). Search by name or content.', + { + query: z.string().describe('Search query (filename pattern or content text)'), + path: z.string().default('C:\\').describe('Directory to search in'), + type: z.enum(['name', 'content']).default('name').describe('Search by filename or file content'), + extension: z.string().optional().describe('File extension filter (e.g. ".txt", ".log")'), + limit: z.number().default(30).describe('Max results'), + }, + async ({ query, path, type, extension, limit }) => { + const extFilter = extension + ? `| Where-Object { $_.Extension -eq '${extension.replace(/'/g, "''")}' }` + : ''; + + let ps: string; + if (type === 'name') { + ps = ` +Get-ChildItem -Path '${path.replace(/'/g, "''")}' -Recurse -File -ErrorAction SilentlyContinue ${extFilter ? '' : ''} | + Where-Object { $_.Name -like '*${query.replace(/'/g, "''")}*' } ${extFilter} | + Select-Object -First ${limit} | + ForEach-Object { + [PSCustomObject]@{ + Path = $_.FullName + Size = $_.Length + Modified = $_.LastWriteTime.ToString('yyyy-MM-dd HH:mm') + Extension = $_.Extension + } + } | ConvertTo-Json -Depth 3 -Compress`; + } else { + ps = ` +Get-ChildItem -Path '${path.replace(/'/g, "''")}' -Recurse -File -ErrorAction SilentlyContinue ${extFilter} | + Select-String -Pattern '${query.replace(/'/g, "''")}' -List -ErrorAction SilentlyContinue | + Select-Object -First ${limit} | + ForEach-Object { + [PSCustomObject]@{ + Path = $_.Path + Line = $_.LineNumber + Match = $_.Line.Trim().Substring(0, [math]::Min($_.Line.Trim().Length, 120)) + } + } | ConvertTo-Json -Depth 3 -Compress`; + } + + const result = await runPowerShell(ps, { timeout: 60000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + if (!result.stdout) { + return { content: [{ type: 'text', text: 'No results found.' }] }; + } + + const results = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + + let text: string; + if (type === 'name') { + text = results.map((r: { Path: string; Size: number; Modified: string }) => + `${String(r.Size).padStart(10)} ${r.Modified} ${r.Path}`, + ).join('\n'); + text = `${'Size'.padStart(10)} ${'Modified'.padEnd(16)} Path\n${'─'.repeat(80)}\n${text}`; + } else { + text = results.map((r: { Path: string; Line: number; Match: string }) => + `${r.Path}:${r.Line}: ${r.Match}`, + ).join('\n'); + } + + return { content: [{ type: 'text', text: `${results.length} result(s):\n\n${text}` }] }; + }, + ); +} + +function makeBar(pct: number): string { + const filled = Math.round(pct / 10); + return '[' + '█'.repeat(filled) + '░'.repeat(10 - filled) + ']'; +} diff --git a/src/tools/environment.ts b/src/tools/environment.ts new file mode 100644 index 0000000..f2df6b6 --- /dev/null +++ b/src/tools/environment.ts @@ -0,0 +1,146 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_env_get (#31), windows_env_set (#32) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerEnvironmentTools(server: McpServer): void { + server.tool( + 'windows_env_get', + 'Get environment variables. Can retrieve a specific variable or list all (user, system, or both).', + { + name: z.string().optional().describe('Variable name (omit to list all)'), + scope: z.enum(['user', 'system', 'both']).default('both').describe('Variable scope'), + }, + async ({ name, scope }) => { + if (name) { + if (name.toUpperCase() === 'PATH') { + const ps = ` +$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') +$sysPath = [Environment]::GetEnvironmentVariable('PATH', 'Machine') +[PSCustomObject]@{ + UserPATH = ($userPath -split ';' | Where-Object { $_ }) + SystemPATH = ($sysPath -split ';' | Where-Object { $_ }) +} | ConvertTo-Json -Depth 3 -Compress`; + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + const p = JSON.parse(result.stdout); + const userPaths = Array.isArray(p.UserPATH) ? p.UserPATH : [p.UserPATH].filter(Boolean); + const sysPaths = Array.isArray(p.SystemPATH) ? p.SystemPATH : [p.SystemPATH].filter(Boolean); + return { + content: [{ + type: 'text', + text: `System PATH (${sysPaths.length} entries):\n${sysPaths.map((p: string) => ` ${p}`).join('\n')}\n\nUser PATH (${userPaths.length} entries):\n${userPaths.map((p: string) => ` ${p}`).join('\n')}`, + }], + }; + } + + const ps = ` +$user = [Environment]::GetEnvironmentVariable('${name.replace(/'/g, "''")}', 'User') +$sys = [Environment]::GetEnvironmentVariable('${name.replace(/'/g, "''")}', 'Machine') +$proc = [Environment]::GetEnvironmentVariable('${name.replace(/'/g, "''")}', 'Process') +[PSCustomObject]@{ Name = '${name.replace(/'/g, "''")}'; User = $user; System = $sys; Process = $proc } | ConvertTo-Json -Compress`; + + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + const v = JSON.parse(result.stdout); + const lines = [`${v.Name}:`]; + if (v.System !== null) lines.push(` System: ${v.System}`); + if (v.User !== null) lines.push(` User: ${v.User}`); + if (v.Process !== null && v.Process !== v.System && v.Process !== v.User) lines.push(` Process: ${v.Process}`); + if (v.System === null && v.User === null) lines.push(' (not set)'); + return { content: [{ type: 'text', text: lines.join('\n') }] }; + } + + // List all + const scopes = scope === 'both' ? ['User', 'Machine'] : [scope === 'user' ? 'User' : 'Machine']; + const parts: string[] = []; + + for (const s of scopes) { + const ps = `[Environment]::GetEnvironmentVariables('${s}').GetEnumerator() | Sort-Object Name | ForEach-Object { [PSCustomObject]@{ Name = $_.Name; Value = $_.Value } } | ConvertTo-Json -Depth 3 -Compress`; + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) continue; + if (!result.stdout) continue; + + const vars = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = vars.map((v: { Name: string; Value: string }) => + ` ${v.Name.padEnd(30)} ${(v.Value || '').slice(0, 60)}`, + ); + parts.push(`${s} Variables (${vars.length}):\n${lines.join('\n')}`); + } + + return { content: [{ type: 'text', text: parts.join('\n\n') }] }; + }, + ); + + server.tool( + 'windows_env_set', + 'Set or remove a persistent environment variable (user or system scope).', + { + name: z.string().describe('Variable name'), + value: z.string().optional().describe('Value to set (omit with action=remove to delete)'), + scope: z.enum(['user', 'system']).default('user').describe('Variable scope'), + action: z.enum(['set', 'remove', 'append_path', 'prepend_path']).default('set').describe('Action'), + }, + async ({ name, value, scope, action }) => { + const target = scope === 'user' ? 'User' : 'Machine'; + let ps: string; + + switch (action) { + case 'set': + if (!value) { + return { content: [{ type: 'text', text: 'Set requires a value.' }], isError: true }; + } + ps = `[Environment]::SetEnvironmentVariable('${name.replace(/'/g, "''")}', '${value.replace(/'/g, "''")}', '${target}'); "Set ${name}=${value} (${target})"`; + break; + case 'remove': + ps = `[Environment]::SetEnvironmentVariable('${name.replace(/'/g, "''")}', $null, '${target}'); "Removed ${name} (${target})"`; + break; + case 'append_path': + if (!value) { + return { content: [{ type: 'text', text: 'append_path requires a value.' }], isError: true }; + } + ps = ` +$current = [Environment]::GetEnvironmentVariable('PATH', '${target}') +$entries = $current -split ';' | Where-Object { $_ } +if ('${value.replace(/'/g, "''")}' -notin $entries) { + $new = ($entries + '${value.replace(/'/g, "''")}') -join ';' + [Environment]::SetEnvironmentVariable('PATH', $new, '${target}') + "Appended '${value}' to ${target} PATH" +} else { + "'${value}' already in ${target} PATH" +}`; + break; + case 'prepend_path': + if (!value) { + return { content: [{ type: 'text', text: 'prepend_path requires a value.' }], isError: true }; + } + ps = ` +$current = [Environment]::GetEnvironmentVariable('PATH', '${target}') +$entries = $current -split ';' | Where-Object { $_ } +if ('${value.replace(/'/g, "''")}' -notin $entries) { + $new = ('${value.replace(/'/g, "''")}' + ';' + ($entries -join ';')) + [Environment]::SetEnvironmentVariable('PATH', $new, '${target}') + "Prepended '${value}' to ${target} PATH" +} else { + "'${value}' already in ${target} PATH" +}`; + break; + } + + const result = await runPowerShell(ps, { timeout: 10000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +} diff --git a/src/tools/execute.ts b/src/tools/execute.ts new file mode 100644 index 0000000..573a6ae --- /dev/null +++ b/src/tools/execute.ts @@ -0,0 +1,35 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_execute — Execute shell commands (#1) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runShell } from '../shell.js'; + +export function registerExecuteTools(server: McpServer): void { + server.tool( + 'windows_execute', + 'Execute a shell command (PowerShell, cmd, or bash). Returns stdout, stderr, and exit code.', + { + command: z.string().describe('The command to execute'), + shell: z.enum(['pwsh', 'cmd', 'bash']).default('pwsh').describe('Shell to use'), + timeout: z.number().optional().describe('Timeout in milliseconds (default 30000)'), + cwd: z.string().optional().describe('Working directory'), + }, + async ({ command, shell, timeout, cwd }) => { + const result = await runShell(command, { shell, timeout, cwd }); + const parts: string[] = []; + + if (result.stdout) parts.push(result.stdout); + if (result.stderr) parts.push(`[stderr]\n${result.stderr}`); + parts.push(`[exit code: ${result.exitCode}]`); + + return { + content: [{ type: 'text', text: parts.join('\n\n') }], + isError: result.exitCode !== 0, + }; + }, + ); +} diff --git a/src/tools/filesystem.ts b/src/tools/filesystem.ts new file mode 100644 index 0000000..1c13f8f --- /dev/null +++ b/src/tools/filesystem.ts @@ -0,0 +1,273 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_file_read (#36), windows_file_write (#37), + * windows_file_edit (#38), windows_search (#39) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { readFile, writeFile, stat, mkdir } from 'node:fs/promises'; +import { dirname, resolve, extname } from 'node:path'; +import { runPowerShell } from '../shell.js'; + +export function registerFilesystemTools(server: McpServer): void { + + // ── windows_file_read ──────────────────────────────────────────────── + + server.tool( + 'windows_file_read', + 'Read a file with line pagination. Supports text, images (base64), and binary detection. Use negative offset for tail behavior.', + { + path: z.string().describe('Absolute file path'), + offset: z.number().default(0).describe('Line offset (0-based, negative = from end)'), + length: z.number().default(200).describe('Number of lines to return'), + }, + async ({ path: filePath, offset, length }) => { + try { + const absPath = resolve(filePath); + const ext = extname(absPath).toLowerCase(); + + // Image files → base64 + if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.svg'].includes(ext)) { + const data = await readFile(absPath); + const mimeMap: Record = { + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp', + '.ico': 'image/x-icon', '.svg': 'image/svg+xml', + }; + return { + content: [{ + type: 'image' as const, + data: data.toString('base64'), + mimeType: mimeMap[ext] || 'application/octet-stream', + }], + }; + } + + // Text files → line pagination + const content = await readFile(absPath, 'utf-8'); + const allLines = content.split('\n'); + const total = allLines.length; + + let start: number; + if (offset < 0) { + start = Math.max(0, total + offset); + } else { + start = Math.min(offset, total); + } + const end = Math.min(start + length, total); + const lines = allLines.slice(start, end); + + const numbered = lines.map((line, i) => `${String(start + i + 1).padStart(5)} ${line}`); + const header = `${absPath} — lines ${start + 1}-${end} of ${total}`; + + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(60)}\n${numbered.join('\n')}` }], + }; + } catch (err) { + return { content: [{ type: 'text', text: `Error reading file: ${err}` }], isError: true }; + } + }, + ); + + // ── windows_file_write ─────────────────────────────────────────────── + + server.tool( + 'windows_file_write', + 'Write or append to a text file. Creates parent directories if needed.', + { + path: z.string().describe('Absolute file path'), + content: z.string().describe('Content to write'), + mode: z.enum(['write', 'append']).default('write').describe('Write mode'), + }, + async ({ path: filePath, content, mode }) => { + try { + const absPath = resolve(filePath); + await mkdir(dirname(absPath), { recursive: true }); + + if (mode === 'append') { + const existing = await readFile(absPath, 'utf-8').catch(() => ''); + await writeFile(absPath, existing + content, 'utf-8'); + } else { + await writeFile(absPath, content, 'utf-8'); + } + + const info = await stat(absPath); + return { + content: [{ + type: 'text', + text: `Written: ${absPath} (${info.size} bytes, mode: ${mode})`, + }], + }; + } catch (err) { + return { content: [{ type: 'text', text: `Error writing file: ${err}` }], isError: true }; + } + }, + ); + + // ── windows_file_edit ──────────────────────────────────────────────── + + server.tool( + 'windows_file_edit', + 'Surgical file edit with find/replace. Validates expected replacement count.', + { + path: z.string().describe('Absolute file path'), + old_string: z.string().describe('Text to find'), + new_string: z.string().describe('Replacement text'), + expected_count: z.number().optional().describe('Expected number of replacements (fails if mismatch)'), + replace_all: z.boolean().default(false).describe('Replace all occurrences'), + }, + async ({ path: filePath, old_string, new_string, expected_count, replace_all }) => { + try { + const absPath = resolve(filePath); + const content = await readFile(absPath, 'utf-8'); + + // Count occurrences + let count = 0; + let idx = 0; + while ((idx = content.indexOf(old_string, idx)) !== -1) { + count++; + idx += old_string.length; + } + + if (count === 0) { + // Try to find near-matches for helpful error + const lines = content.split('\n'); + const needle = old_string.trim().split('\n')[0].trim(); + const nearMatches = lines + .map((line, i) => ({ line: line.trim(), num: i + 1 })) + .filter(({ line }) => { + if (!needle) return false; + // Simple similarity: shared words + const words = needle.toLowerCase().split(/\s+/); + const lineWords = line.toLowerCase().split(/\s+/); + const shared = words.filter(w => lineWords.includes(w)).length; + return shared >= Math.ceil(words.length * 0.5); + }) + .slice(0, 3); + + let msg = `No matches found for the specified text.`; + if (nearMatches.length > 0) { + msg += `\n\nNear matches:\n${nearMatches.map(m => ` Line ${m.num}: ${m.line}`).join('\n')}`; + } + return { content: [{ type: 'text', text: msg }], isError: true }; + } + + if (expected_count !== undefined && count !== expected_count) { + return { + content: [{ + type: 'text', + text: `Expected ${expected_count} occurrence(s) but found ${count}. No changes made.`, + }], + isError: true, + }; + } + + let result: string; + if (replace_all) { + result = content.split(old_string).join(new_string); + } else { + const pos = content.indexOf(old_string); + result = content.slice(0, pos) + new_string + content.slice(pos + old_string.length); + } + + await writeFile(absPath, result, 'utf-8'); + + const replaced = replace_all ? count : 1; + return { + content: [{ + type: 'text', + text: `Replaced ${replaced} occurrence(s) in ${absPath}`, + }], + }; + } catch (err) { + return { content: [{ type: 'text', text: `Error editing file: ${err}` }], isError: true }; + } + }, + ); + + // ── windows_search ─────────────────────────────────────────────────── + + server.tool( + 'windows_search', + 'Search for files by name pattern or search file contents. Uses ripgrep if available, falls back to PowerShell.', + { + type: z.enum(['files', 'content']).describe('Search type: "files" for filename, "content" for inside files'), + pattern: z.string().describe('Search pattern (glob for files, regex or literal for content)'), + path: z.string().default('.').describe('Directory to search in'), + case_sensitive: z.boolean().default(false).describe('Case-sensitive search'), + file_pattern: z.string().optional().describe('Filter files by pattern (e.g. "*.ts") — only for content search'), + context_lines: z.number().default(0).describe('Lines of context around content matches'), + limit: z.number().default(50).describe('Max results'), + }, + async ({ type, pattern, path: searchPath, case_sensitive, file_pattern, context_lines, limit }) => { + const absPath = resolve(searchPath); + + if (type === 'files') { + const caseSense = case_sensitive ? '' : '-i'; + const ps = ` +Get-ChildItem -Path '${absPath.replace(/'/g, "''")}' -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name ${caseSense ? '-cmatch' : '-match'} '${pattern.replace(/'/g, "''")}' } | + Select-Object -First ${limit} | + ForEach-Object { + "$($_.Length.ToString().PadLeft(10)) $($_.LastWriteTime.ToString('yyyy-MM-dd HH:mm')) $($_.FullName)" + }`; + const result = await runPowerShell(ps, { timeout: 30000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + const lines = result.stdout ? result.stdout.split('\n') : []; + return { + content: [{ + type: 'text', + text: lines.length > 0 + ? `${lines.length} file(s) found:\n\n${' Size Modified Path'}\n${lines.join('\n')}` + : 'No files found.', + }], + }; + } + + // Content search — try ripgrep first, fall back to PowerShell + const caseFlag = case_sensitive ? '' : '-i'; + const contextFlag = context_lines > 0 ? `-C ${context_lines}` : ''; + const fileFlag = file_pattern ? `--glob '${file_pattern}'` : ''; + + // Try rg first + const rgCmd = `rg ${caseFlag} ${contextFlag} ${fileFlag} --max-count 5 -n '${pattern.replace(/'/g, "\\'")}' '${absPath.replace(/'/g, "\\'")}'`; + const rgResult = await runPowerShell(`& { ${rgCmd} } 2>$null | Select-Object -First ${limit * 3}`, { timeout: 30000 }); + + if (rgResult.exitCode === 0 && rgResult.stdout) { + const lines = rgResult.stdout.split('\n'); + return { + content: [{ + type: 'text', + text: `Content search results (ripgrep):\n\n${lines.join('\n')}`, + }], + }; + } + + // Fallback: PowerShell Select-String + const fileFilter = file_pattern ? `-Include '${file_pattern}'` : ''; + const ps = ` +Get-ChildItem -Path '${absPath.replace(/'/g, "''")}' -Recurse -File ${fileFilter} -ErrorAction SilentlyContinue | + Select-String -Pattern '${pattern.replace(/'/g, "''")}' ${case_sensitive ? '-CaseSensitive' : ''} -Context ${context_lines} | + Select-Object -First ${limit} | + ForEach-Object { "$($_.Path):$($_.LineNumber): $($_.Line.Trim())" }`; + + const result = await runPowerShell(ps, { timeout: 60000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + const lines = result.stdout ? result.stdout.split('\n') : []; + return { + content: [{ + type: 'text', + text: lines.length > 0 + ? `${lines.length} match(es) found:\n\n${lines.join('\n')}` + : 'No matches found.', + }], + }; + }, + ); +} diff --git a/src/tools/netstat.ts b/src/tools/netstat.ts new file mode 100644 index 0000000..9fc8968 --- /dev/null +++ b/src/tools/netstat.ts @@ -0,0 +1,72 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_network_connections (#23) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerNetstatTools(server: McpServer): void { + server.tool( + 'windows_network_connections', + 'List active TCP/UDP network connections (like netstat). Shows local/remote address, state, and owning process.', + { + state: z.enum(['all', 'listen', 'established', 'time_wait', 'close_wait']).default('all').describe('Filter by state'), + port: z.number().optional().describe('Filter by port number'), + process_name: z.string().optional().describe('Filter by process name'), + limit: z.number().default(50).describe('Max results'), + }, + async ({ state, port, process_name, limit }) => { + const stateMap: Record = { + listen: "| Where-Object { $_.State -eq 'Listen' }", + established: "| Where-Object { $_.State -eq 'Established' }", + time_wait: "| Where-Object { $_.State -eq 'TimeWait' }", + close_wait: "| Where-Object { $_.State -eq 'CloseWait' }", + all: '', + }; + + const portFilter = port + ? `| Where-Object { $_.LocalPort -eq ${port} -or $_.RemotePort -eq ${port} }` + : ''; + + const procFilter = process_name + ? `| Where-Object { $procName -like '*${process_name.replace(/'/g, "''")}*' }` + : ''; + + const ps = ` +Get-NetTCPConnection -ErrorAction SilentlyContinue ${stateMap[state]} ${portFilter} | ForEach-Object { + $proc = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue + $procName = if ($proc) { $proc.ProcessName } else { '?' } + [PSCustomObject]@{ + Proto = 'TCP' + LocalAddr = "$($_.LocalAddress):$($_.LocalPort)" + RemoteAddr = "$($_.RemoteAddress):$($_.RemotePort)" + State = $_.State.ToString() + PID = $_.OwningProcess + Process = $procName + } +} ${procFilter} | Select-Object -First ${limit} | ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps, { timeout: 15000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + if (!result.stdout) { + return { content: [{ type: 'text', text: 'No connections found.' }] }; + } + + const conns = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = conns.map((c: { Proto: string; LocalAddr: string; RemoteAddr: string; State: string; PID: number; Process: string }) => + `${c.Proto} ${c.LocalAddr.padEnd(22)} ${c.RemoteAddr.padEnd(22)} ${c.State.padEnd(12)} ${String(c.PID).padStart(6)} ${c.Process}`, + ); + + const header = `Proto ${'Local Address'.padEnd(22)} ${'Remote Address'.padEnd(22)} ${'State'.padEnd(12)} ${'PID'.padStart(6)} Process`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(100)}\n${lines.join('\n')}\n\n${conns.length} connections` }], + }; + }, + ); +} diff --git a/src/tools/network.ts b/src/tools/network.ts new file mode 100644 index 0000000..63d8283 --- /dev/null +++ b/src/tools/network.ts @@ -0,0 +1,84 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_network_info (#22) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { runPowerShell } from '../shell.js'; + +export function registerNetworkTools(server: McpServer): void { + server.tool( + 'windows_network_info', + 'Get network configuration: adapters, IPs, DNS, gateway, Wi-Fi status, and connectivity.', + {}, + async () => { + const ps = ` +$adapters = Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | ForEach-Object { + $ipInfo = Get-NetIPAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue + $dns = (Get-DnsClientServerAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue).ServerAddresses + $gw = (Get-NetRoute -InterfaceIndex $_.ifIndex -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue).NextHop + [PSCustomObject]@{ + Name = $_.Name + Description = $_.InterfaceDescription + Type = $_.MediaType + Status = $_.Status + Speed = $_.LinkSpeed + MAC = $_.MacAddress + IP = if ($ipInfo) { $ipInfo.IPAddress -join ', ' } else { 'N/A' } + Subnet = if ($ipInfo) { $ipInfo.PrefixLength -join ', ' } else { 'N/A' } + Gateway = if ($gw) { $gw -join ', ' } else { 'N/A' } + DNS = if ($dns) { $dns -join ', ' } else { 'N/A' } + } +} + +# Wi-Fi info +$wifi = $null +try { + $wifiProfile = netsh wlan show interfaces 2>$null + if ($wifiProfile) { + $ssid = ($wifiProfile | Select-String 'SSID\s+:' | Select-Object -First 1) -replace '.*:\s*','' + $signal = ($wifiProfile | Select-String 'Signal' | Select-Object -First 1) -replace '.*:\s*','' + $auth = ($wifiProfile | Select-String 'Authentication' | Select-Object -First 1) -replace '.*:\s*','' + $wifi = [PSCustomObject]@{ SSID = $ssid.Trim(); Signal = $signal.Trim(); Security = $auth.Trim() } + } +} catch {} + +# Connectivity +$connected = Test-Connection -ComputerName 8.8.8.8 -Count 1 -Quiet -ErrorAction SilentlyContinue + +[PSCustomObject]@{ + Adapters = $adapters + WiFi = $wifi + InternetConnected = $connected +} | 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[] = []; + + lines.push(`Internet: ${info.InternetConnected ? 'Connected' : 'Disconnected'}`); + + if (info.WiFi) { + lines.push(`Wi-Fi: ${info.WiFi.SSID} (${info.WiFi.Signal}, ${info.WiFi.Security})`); + } + + lines.push(''); + lines.push('Adapters:'); + + const adapters = Array.isArray(info.Adapters) ? info.Adapters : [info.Adapters]; + for (const a of adapters.filter(Boolean)) { + lines.push(` ${a.Name} (${a.Description})`); + lines.push(` IP: ${a.IP}/${a.Subnet} Gateway: ${a.Gateway}`); + lines.push(` DNS: ${a.DNS}`); + lines.push(` MAC: ${a.MAC} Speed: ${a.Speed}`); + } + + return { content: [{ type: 'text', text: lines.join('\n') }] }; + }, + ); +} diff --git a/src/tools/notification.ts b/src/tools/notification.ts new file mode 100644 index 0000000..9feefcd --- /dev/null +++ b/src/tools/notification.ts @@ -0,0 +1,50 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_notification_send (#20) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerNotificationTools(server: McpServer): void { + server.tool( + 'windows_notification_send', + 'Send a Windows toast notification with title, body, and optional icon.', + { + title: z.string().describe('Notification title'), + body: z.string().describe('Notification body text'), + icon: z.string().optional().describe('Path to icon file (optional)'), + sound: z.boolean().default(true).describe('Play notification sound'), + }, + async ({ title, body, icon, sound }) => { + const iconParam = icon + ? `$notify.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon('${icon.replace(/'/g, "''")}');` + : `$notify.Icon = [System.Drawing.SystemIcons]::Information;`; + + const ps = ` +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$notify = New-Object System.Windows.Forms.NotifyIcon +${iconParam} +$notify.Visible = $true +$notify.BalloonTipTitle = '${title.replace(/'/g, "''")}' +$notify.BalloonTipText = '${body.replace(/'/g, "''")}' +$notify.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Info +$notify.ShowBalloonTip(5000) + +# Keep alive briefly so notification displays +Start-Sleep -Milliseconds 100 +$notify.Dispose() +"Notification sent: ${title.replace(/"/g, '\\"')}"`; + + const result = await runPowerShell(ps, { timeout: 10000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +} diff --git a/src/tools/power.ts b/src/tools/power.ts new file mode 100644 index 0000000..03a6bda --- /dev/null +++ b/src/tools/power.ts @@ -0,0 +1,123 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_power_get (#12), windows_power_action (#13) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerPowerTools(server: McpServer): void { + server.tool( + 'windows_power_get', + 'Get power state: battery level, AC/battery, power plan, screen/sleep timeouts.', + {}, + async () => { + const ps = ` +$battery = Get-CimInstance Win32_Battery -ErrorAction SilentlyContinue +$plan = powercfg /getactivescheme 2>$null +$planName = if ($plan) { ($plan -replace '^.*\\((.*)\\).*$','$1').Trim() } else { 'Unknown' } + +# Get timeout values +$acScreen = (powercfg /query SCHEME_CURRENT SUB_VIDEO VIDEOIDLE 2>$null | Select-String 'Current AC Power Setting Index' | ForEach-Object { ($_ -split '0x')[1] }) -as [int] +$dcScreen = (powercfg /query SCHEME_CURRENT SUB_VIDEO VIDEOIDLE 2>$null | Select-String 'Current DC Power Setting Index' | ForEach-Object { ($_ -split '0x')[1] }) -as [int] +$acSleep = (powercfg /query SCHEME_CURRENT SUB_SLEEP STANDBYIDLE 2>$null | Select-String 'Current AC Power Setting Index' | ForEach-Object { ($_ -split '0x')[1] }) -as [int] +$dcSleep = (powercfg /query SCHEME_CURRENT SUB_SLEEP STANDBYIDLE 2>$null | Select-String 'Current DC Power Setting Index' | ForEach-Object { ($_ -split '0x')[1] }) -as [int] + +[PSCustomObject]@{ + HasBattery = $null -ne $battery + BatteryPct = if ($battery) { $battery.EstimatedChargeRemaining } else { $null } + Charging = if ($battery) { $battery.BatteryStatus -eq 2 } else { $null } + ACPower = if ($battery) { $battery.BatteryStatus -eq 2 -or $battery.BatteryStatus -eq 6 } else { $true } + TimeRemaining = if ($battery -and $battery.EstimatedRunTime -and $battery.EstimatedRunTime -lt 71582788) { "$([math]::Floor($battery.EstimatedRunTime / 60))h $($battery.EstimatedRunTime % 60)m" } else { $null } + PowerPlan = $planName + ScreenTimeout_AC = if ($acScreen) { "$([math]::Floor($acScreen / 60))m" } else { 'Never' } + ScreenTimeout_DC = if ($dcScreen) { "$([math]::Floor($dcScreen / 60))m" } else { 'Never' } + SleepTimeout_AC = if ($acSleep) { "$([math]::Floor($acSleep / 60))m" } else { 'Never' } + SleepTimeout_DC = if ($dcSleep) { "$([math]::Floor($dcSleep / 60))m" } else { 'Never' } +} | 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); + const lines: string[] = []; + + if (info.HasBattery) { + const icon = info.Charging ? '🔌' : '🔋'; + lines.push(`${icon} Battery: ${info.BatteryPct}%${info.Charging ? ' (charging)' : ''}`); + if (info.TimeRemaining) lines.push(`Estimated remaining: ${info.TimeRemaining}`); + } else { + lines.push(`AC Power (no battery)`); + } + + lines.push(`Power Plan: ${info.PowerPlan}`); + lines.push(``); + lines.push(`Screen timeout: ${info.ScreenTimeout_AC} (AC) / ${info.ScreenTimeout_DC} (battery)`); + lines.push(`Sleep timeout: ${info.SleepTimeout_AC} (AC) / ${info.SleepTimeout_DC} (battery)`); + + return { content: [{ type: 'text', text: lines.join('\n') }] }; + }, + ); + + server.tool( + 'windows_power_action', + 'Execute power actions: sleep, hibernate, lock, shutdown, restart, or switch power plan.', + { + action: z.enum(['sleep', 'hibernate', 'lock', 'shutdown', 'restart', 'logoff', 'plan']).describe('Power action'), + delay: z.number().optional().describe('Delay in seconds (for shutdown/restart)'), + cancel: z.boolean().optional().describe('Cancel a scheduled shutdown/restart'), + plan: z.enum(['balanced', 'performance', 'powersaver']).optional().describe('Power plan to switch to (when action=plan)'), + }, + async ({ action, delay, cancel, plan }) => { + if (cancel) { + const result = await runPowerShell('shutdown /a 2>&1; "Scheduled shutdown cancelled"'); + return { content: [{ type: 'text', text: result.stdout || result.stderr }] }; + } + + let ps: string; + const delayArg = delay ? `/t ${delay}` : ''; + + switch (action) { + case 'sleep': + ps = `Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Application]::SetSuspendState('Suspend', $false, $false); "Sleeping..."`; + break; + case 'hibernate': + ps = `Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Application]::SetSuspendState('Hibernate', $false, $false); "Hibernating..."`; + break; + case 'lock': + ps = `rundll32.exe user32.dll,LockWorkStation; "Workstation locked"`; + break; + case 'shutdown': + ps = `shutdown /s /f ${delayArg}; "Shutdown initiated${delay ? ` in ${delay}s` : ''}"`; + break; + case 'restart': + ps = `shutdown /r /f ${delayArg}; "Restart initiated${delay ? ` in ${delay}s` : ''}"`; + break; + case 'logoff': + ps = `shutdown /l; "Logging off..."`; + break; + case 'plan': + if (!plan) { + return { content: [{ type: 'text', text: 'Specify a plan: balanced, performance, or powersaver' }], isError: true }; + } + const planGuids: Record = { + balanced: '381b4222-f694-41f0-9685-ff5bb260df2e', + performance: '8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c', + powersaver: 'a1841308-3541-4fab-bc81-f71556f20b4a', + }; + ps = `powercfg /setactive ${planGuids[plan]}; $p = powercfg /getactivescheme; "Switched to: $($p -replace '^.*\\((.*)\\).*$','$1')"`; + break; + } + + const result = await runPowerShell(ps, { timeout: 10000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +} diff --git a/src/tools/process.ts b/src/tools/process.ts new file mode 100644 index 0000000..e6ea5b9 --- /dev/null +++ b/src/tools/process.ts @@ -0,0 +1,80 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_process_list — List running processes (#2) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +interface ProcessInfo { + PID: number; + Name: string; + CPU: number; + MemoryMB: number; + WindowTitle: string; + Path: string; +} + +export function registerProcessTools(server: McpServer): void { + server.tool( + 'windows_process_list', + 'List running processes with PID, name, CPU, memory, window title, and path.', + { + filter: z.string().optional().describe('Filter by process name (substring match)'), + sort: z.enum(['cpu', 'memory', 'name']).default('memory').describe('Sort order'), + limit: z.number().default(50).describe('Max results to return'), + }, + async ({ filter, sort, limit }) => { + const filterClause = filter + ? `| Where-Object { $_.Name -like '*${filter.replace(/'/g, "''")}*' }` + : ''; + + const sortClause = sort === 'cpu' + ? '| Sort-Object CPU -Descending' + : sort === 'name' + ? '| Sort-Object Name' + : '| Sort-Object WorkingSet64 -Descending'; + + const ps = ` +Get-Process ${filterClause} ${sortClause} | Select-Object -First ${limit} | +ForEach-Object { + [PSCustomObject]@{ + PID = $_.Id + Name = $_.ProcessName + CPU = [math]::Round($_.CPU, 1) + MemoryMB = [math]::Round($_.WorkingSet64 / 1MB, 1) + WindowTitle = $_.MainWindowTitle + Path = $_.Path + } +} | ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps); + + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + let processes: ProcessInfo[] = []; + if (result.stdout) { + const parsed = JSON.parse(result.stdout); + processes = Array.isArray(parsed) ? parsed : [parsed]; + } + + const lines = processes.map(p => + `${String(p.PID).padStart(7)} ${p.Name.padEnd(25).slice(0, 25)} ${String(p.CPU ?? 0).padStart(8)}s ${String(p.MemoryMB).padStart(8)} MB ${p.WindowTitle || ''}`, + ); + + const header = `${'PID'.padStart(7)} ${'Name'.padEnd(25)} ${'CPU'.padStart(8)} ${'Memory'.padStart(8)} Window Title`; + const separator = '-'.repeat(90); + + return { + content: [{ + type: 'text', + text: `${header}\n${separator}\n${lines.join('\n')}\n\n${processes.length} processes`, + }], + }; + }, + ); +} diff --git a/src/tools/process_kill.ts b/src/tools/process_kill.ts new file mode 100644 index 0000000..2961f5b --- /dev/null +++ b/src/tools/process_kill.ts @@ -0,0 +1,57 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_process_kill (#3) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerProcessKillTools(server: McpServer): void { + server.tool( + 'windows_process_kill', + 'Terminate running processes by PID or name.', + { + pid: z.number().optional().describe('Process ID to kill'), + name: z.string().optional().describe('Process name to kill (all matching)'), + force: z.boolean().default(false).describe('Force termination'), + }, + async ({ pid, name, force }) => { + if (!pid && !name) { + return { content: [{ type: 'text', text: 'Provide either pid or name.' }], isError: true }; + } + + const forceFlag = force ? ' -Force' : ''; + let ps: string; + + if (pid) { + ps = ` +$p = Get-Process -Id ${pid} -ErrorAction SilentlyContinue +if ($p) { + $n = $p.ProcessName + Stop-Process -Id ${pid}${forceFlag} -ErrorAction Stop + "Killed PID ${pid} ($n)" +} else { + "No process with PID ${pid}" +}`; + } else { + ps = ` +$procs = Get-Process -Name '${name!.replace(/'/g, "''")}' -ErrorAction SilentlyContinue +if ($procs) { + $count = @($procs).Count + $procs | Stop-Process${forceFlag} -ErrorAction Stop + "Killed $count process(es) named '${name}'" +} else { + "No processes named '${name}'" +}`; + } + + const result = await runPowerShell(ps); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +} diff --git a/src/tools/recycle_bin.ts b/src/tools/recycle_bin.ts new file mode 100644 index 0000000..c0cad40 --- /dev/null +++ b/src/tools/recycle_bin.ts @@ -0,0 +1,126 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_recycle_bin (#26) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerRecycleBinTools(server: McpServer): void { + server.tool( + 'windows_recycle_bin', + 'Manage the Recycle Bin: list items, get size, restore items, or empty.', + { + action: z.enum(['list', 'size', 'empty', 'restore']).default('list').describe('Action'), + filter: z.string().optional().describe('Filter by filename (for list/restore)'), + limit: z.number().default(30).describe('Max items (for list)'), + }, + async ({ action, filter, limit }) => { + switch (action) { + case 'size': { + const ps = ` +$shell = New-Object -ComObject Shell.Application +$bin = $shell.Namespace(10) +$items = $bin.Items() +$count = $items.Count +$totalSize = 0 +for ($i = 0; $i -lt $count; $i++) { + $totalSize += $bin.GetDetailsOf($items.Item($i), 2) -replace '[^0-9]','' -as [long] +} +[PSCustomObject]@{ + Count = $count + SizeMB = [math]::Round($totalSize / 1MB, 1) +} | ConvertTo-Json -Compress`; + + const result = await runPowerShell(ps, { timeout: 15000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + const info = JSON.parse(result.stdout); + return { content: [{ type: 'text', text: `Recycle Bin: ${info.Count} items, ${info.SizeMB} MB` }] }; + } + + case 'list': { + const filterClause = filter + ? `| Where-Object { $_.Name -like '*${filter.replace(/'/g, "''")}*' }` + : ''; + + const ps = ` +$shell = New-Object -ComObject Shell.Application +$bin = $shell.Namespace(10) +$items = @() +foreach ($item in $bin.Items()) { + $items += [PSCustomObject]@{ + Name = $item.Name + OriginalPath = $bin.GetDetailsOf($item, 1) + Size = $bin.GetDetailsOf($item, 2) + DeletedDate = $bin.GetDetailsOf($item, 3) + Type = $bin.GetDetailsOf($item, 4) + } +} +$items ${filterClause} | Select-Object -First ${limit} | ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps, { timeout: 20000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + if (!result.stdout) { + return { content: [{ type: 'text', text: 'Recycle Bin is empty.' }] }; + } + + const items = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = items.map((i: { Name: string; OriginalPath: string; Size: string; DeletedDate: string }) => + `${(i.Name || '').padEnd(35).slice(0, 35)} ${(i.Size || '').padStart(10)} ${(i.DeletedDate || '').padEnd(20)} ${(i.OriginalPath || '').slice(0, 40)}`, + ); + + const header = `${'Name'.padEnd(35)} ${'Size'.padStart(10)} ${'Deleted'.padEnd(20)} Original Path`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(110)}\n${lines.join('\n')}\n\n${items.length} items` }], + }; + } + + case 'empty': { + const ps = ` +$shell = New-Object -ComObject Shell.Application +$count = $shell.Namespace(10).Items().Count +if ($count -eq 0) { "Recycle Bin is already empty." } +else { + Clear-RecycleBin -Force -ErrorAction Stop + "Emptied Recycle Bin ($count items removed)" +}`; + const result = await runPowerShell(ps, { timeout: 15000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + } + + case 'restore': { + if (!filter) { + return { content: [{ type: 'text', text: 'Restore requires a filter to identify which item(s) to restore.' }], isError: true }; + } + const ps = ` +$shell = New-Object -ComObject Shell.Application +$bin = $shell.Namespace(10) +$restored = 0 +foreach ($item in $bin.Items()) { + if ($item.Name -like '*${filter.replace(/'/g, "''")}*') { + $origPath = $bin.GetDetailsOf($item, 1) + $item.InvokeVerb('undelete') + $restored++ + } +} +if ($restored -gt 0) { "Restored $restored item(s)" } else { "No matching items found" }`; + + const result = await runPowerShell(ps, { timeout: 15000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + } + } + }, + ); +} diff --git a/src/tools/registry.ts b/src/tools/registry.ts new file mode 100644 index 0000000..bbfa31b --- /dev/null +++ b/src/tools/registry.ts @@ -0,0 +1,165 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_registry_read (#29), windows_registry_write (#30) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +function expandHive(path: string): string { + return path + .replace(/^HKLM[:\\\/]/i, 'HKLM:\\') + .replace(/^HKCU[:\\\/]/i, 'HKCU:\\') + .replace(/^HKCR[:\\\/]/i, 'HKCR:\\') + .replace(/^HKU[:\\\/]/i, 'HKU:\\') + .replace(/^HKCC[:\\\/]/i, 'HKCC:\\'); +} + +export function registerRegistryTools(server: McpServer): void { + server.tool( + 'windows_registry_read', + 'Read Windows Registry keys, subkeys, and values. Supports HKLM, HKCU, HKCR abbreviations.', + { + path: z.string().describe('Registry path (e.g. "HKCU:\\Software\\Microsoft")'), + value: z.string().optional().describe('Specific value name to read (omit to list all values)'), + subkeys: z.boolean().default(false).describe('List subkeys instead of values'), + }, + async ({ path, value, subkeys }) => { + const regPath = expandHive(path); + + if (subkeys) { + const ps = ` +Get-ChildItem -Path '${regPath.replace(/'/g, "''")}' -ErrorAction Stop | ForEach-Object { + [PSCustomObject]@{ + Name = $_.PSChildName + SubKeyCount = $_.SubKeyCount + ValueCount = $_.ValueCount + } +} | ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + if (!result.stdout) { + return { content: [{ type: 'text', text: 'No subkeys found.' }] }; + } + + const keys = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = keys.map((k: { Name: string; SubKeyCount: number; ValueCount: number }) => + ` ${k.Name.padEnd(40)} ${k.SubKeyCount} subkeys, ${k.ValueCount} values`, + ); + return { content: [{ type: 'text', text: `${regPath}\n${'─'.repeat(70)}\n${lines.join('\n')}` }] }; + } + + if (value) { + const ps = ` +$v = Get-ItemProperty -Path '${regPath.replace(/'/g, "''")}' -Name '${value.replace(/'/g, "''")}' -ErrorAction Stop +$raw = $v.'${value.replace(/'/g, "''")}' +$kind = (Get-Item -Path '${regPath.replace(/'/g, "''")}' -ErrorAction Stop).GetValueKind('${value.replace(/'/g, "''")}') +[PSCustomObject]@{ + Name = '${value.replace(/'/g, "''")}' + Type = $kind.ToString() + Value = $raw +} | ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + const v = JSON.parse(result.stdout); + return { content: [{ type: 'text', text: `${regPath}\\${v.Name}\nType: ${v.Type}\nValue: ${JSON.stringify(v.Value)}` }] }; + } + + // List all values + const ps = ` +$key = Get-Item -Path '${regPath.replace(/'/g, "''")}' -ErrorAction Stop +$key.GetValueNames() | ForEach-Object { + $name = $_ + $val = $key.GetValue($name) + $kind = $key.GetValueKind($name) + [PSCustomObject]@{ + Name = if ($name) { $name } else { '(Default)' } + Type = $kind.ToString() + Value = $val + } +} | ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps, { timeout: 10000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + if (!result.stdout) { + return { content: [{ type: 'text', text: 'No values found.' }] }; + } + + const values = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = values.map((v: { Name: string; Type: string; Value: unknown }) => { + const valStr = typeof v.Value === 'string' ? v.Value : JSON.stringify(v.Value); + return ` ${v.Name.padEnd(30)} ${v.Type.padEnd(12)} ${valStr.slice(0, 60)}`; + }); + + const header = ` ${'Name'.padEnd(30)} ${'Type'.padEnd(12)} Value`; + return { content: [{ type: 'text', text: `${regPath}\n${header}\n${'─'.repeat(80)}\n${lines.join('\n')}` }] }; + }, + ); + + server.tool( + 'windows_registry_write', + 'Write Windows Registry values. Restricted to HKCU by default. Use hklm_override for HKLM writes.', + { + path: z.string().describe('Registry path'), + name: z.string().describe('Value name'), + value: z.string().describe('Value data'), + type: z.enum(['String', 'DWord', 'QWord', 'Binary', 'ExpandString', 'MultiString']).default('String').describe('Value type'), + hklm_override: z.boolean().default(false).describe('Allow writing to HKLM (requires elevation)'), + action: z.enum(['set', 'delete', 'create_key', 'delete_key']).default('set').describe('Action'), + }, + async ({ path, name, value, type, hklm_override, action }) => { + const regPath = expandHive(path); + + // Safety check + if (regPath.startsWith('HKLM:') && !hklm_override) { + return { + content: [{ type: 'text', text: 'HKLM writes are restricted. Set hklm_override=true and ensure elevation.' }], + isError: true, + }; + } + + let ps: string; + + switch (action) { + case 'set': { + const typeMap: Record = { + String: 'String', DWord: 'DWord', QWord: 'QWord', + Binary: 'Binary', ExpandString: 'ExpandString', MultiString: 'MultiString', + }; + ps = ` +if (-not (Test-Path '${regPath.replace(/'/g, "''")}')) { + New-Item -Path '${regPath.replace(/'/g, "''")}' -Force | Out-Null +} +Set-ItemProperty -Path '${regPath.replace(/'/g, "''")}' -Name '${name.replace(/'/g, "''")}' -Value '${value.replace(/'/g, "''")}' -Type ${typeMap[type]} -ErrorAction Stop +"Set ${regPath}\\${name} = ${value} (${type})"`; + break; + } + case 'delete': + ps = `Remove-ItemProperty -Path '${regPath.replace(/'/g, "''")}' -Name '${name.replace(/'/g, "''")}' -ErrorAction Stop; "Deleted ${regPath}\\${name}"`; + break; + case 'create_key': + ps = `New-Item -Path '${regPath.replace(/'/g, "''")}' -Force -ErrorAction Stop | Out-Null; "Created key ${regPath}"`; + break; + case 'delete_key': + ps = `Remove-Item -Path '${regPath.replace(/'/g, "''")}' -Recurse -Force -ErrorAction Stop; "Deleted key ${regPath}"`; + break; + } + + const result = await runPowerShell(ps, { timeout: 10000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +} diff --git a/src/tools/scheduler.ts b/src/tools/scheduler.ts new file mode 100644 index 0000000..46bfdf9 --- /dev/null +++ b/src/tools/scheduler.ts @@ -0,0 +1,140 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_task_scheduler_list (#27), windows_task_scheduler_manage (#28) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerSchedulerTools(server: McpServer): void { + server.tool( + 'windows_task_scheduler_list', + 'List Windows Task Scheduler tasks with status, last/next run, and trigger type.', + { + folder: z.string().default('\\').describe('Task folder path (e.g. "\\" for root, "\\Microsoft\\")'), + filter: z.string().optional().describe('Filter by task name (substring)'), + }, + async ({ folder, filter }) => { + const filterClause = filter + ? `| Where-Object { $_.TaskName -like '*${filter.replace(/'/g, "''")}*' }` + : ''; + + const ps = ` +Get-ScheduledTask -TaskPath '${folder.replace(/'/g, "''")}*' -ErrorAction SilentlyContinue ${filterClause} | ForEach-Object { + $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue + [PSCustomObject]@{ + Name = $_.TaskName + Path = $_.TaskPath + State = $_.State.ToString() + LastRun = if ($info.LastRunTime -and $info.LastRunTime.Year -gt 1999) { $info.LastRunTime.ToString('yyyy-MM-dd HH:mm') } else { 'Never' } + NextRun = if ($info.NextRunTime -and $info.NextRunTime.Year -gt 1999) { $info.NextRunTime.ToString('yyyy-MM-dd HH:mm') } else { 'N/A' } + LastResult = if ($info) { '0x{0:X}' -f $info.LastTaskResult } else { 'N/A' } + Triggers = ($_.Triggers | ForEach-Object { $_.CimClass.CimClassName -replace 'MSFT_Task',''-replace 'Trigger','' }) -join ', ' + } +} | ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps, { timeout: 30000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + if (!result.stdout) { + return { content: [{ type: 'text', text: 'No tasks found.' }] }; + } + + const tasks = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = tasks.map((t: { Name: string; State: string; LastRun: string; NextRun: string; Triggers: string }) => { + const state = t.State === 'Ready' ? '[RDY]' : t.State === 'Running' ? '[RUN]' : t.State === 'Disabled' ? '[OFF]' : `[${t.State.slice(0, 3).toUpperCase()}]`; + return `${state} ${t.Name.padEnd(40).slice(0, 40)} ${t.LastRun.padEnd(16)} ${t.NextRun.padEnd(16)} ${t.Triggers}`; + }); + + const header = `State ${'Name'.padEnd(40)} ${'Last Run'.padEnd(16)} ${'Next Run'.padEnd(16)} Triggers`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(110)}\n${lines.join('\n')}\n\n${tasks.length} tasks` }], + }; + }, + ); + + server.tool( + 'windows_task_scheduler_manage', + 'Create, delete, enable, disable, or run a scheduled task.', + { + action: z.enum(['create', 'delete', 'enable', 'disable', 'run']).describe('Action to perform'), + name: z.string().describe('Task name'), + command: z.string().optional().describe('Command to execute (for create)'), + arguments: z.string().optional().describe('Command arguments (for create)'), + trigger: z.enum(['once', 'daily', 'weekly', 'hourly', 'logon', 'startup']).optional().describe('Trigger type (for create)'), + time: z.string().optional().describe('Time for trigger as HH:mm (for create with once/daily/weekly)'), + interval: z.number().optional().describe('Repetition interval in minutes (for create with hourly)'), + }, + async ({ action, name, command, arguments: args, trigger, time, interval }) => { + let ps: string; + + switch (action) { + case 'run': + ps = `Start-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -ErrorAction Stop; "Task '${name}' started"`; + break; + case 'enable': + ps = `Enable-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -ErrorAction Stop | Select-Object TaskName,State | ConvertTo-Json -Compress`; + break; + case 'disable': + ps = `Disable-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -ErrorAction Stop | Select-Object TaskName,State | ConvertTo-Json -Compress`; + break; + case 'delete': + ps = `Unregister-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -Confirm:$false -ErrorAction Stop; "Task '${name}' deleted"`; + break; + case 'create': { + if (!command) { + return { content: [{ type: 'text', text: 'Create requires command.' }], isError: true }; + } + if (!trigger) { + return { content: [{ type: 'text', text: 'Create requires trigger type.' }], isError: true }; + } + + const actionPart = args + ? `$action = New-ScheduledTaskAction -Execute '${command.replace(/'/g, "''")}' -Argument '${args.replace(/'/g, "''")}'` + : `$action = New-ScheduledTaskAction -Execute '${command.replace(/'/g, "''")}'`; + + let triggerPart: string; + switch (trigger) { + case 'once': + triggerPart = `$trigger = New-ScheduledTaskTrigger -Once -At '${time || '00:00'}'`; + break; + case 'daily': + triggerPart = `$trigger = New-ScheduledTaskTrigger -Daily -At '${time || '00:00'}'`; + break; + case 'weekly': + triggerPart = `$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At '${time || '00:00'}'`; + break; + case 'hourly': + triggerPart = `$trigger = New-ScheduledTaskTrigger -Once -At '00:00' -RepetitionInterval (New-TimeSpan -Minutes ${interval || 60}) -RepetitionDuration (New-TimeSpan -Days 9999)`; + break; + case 'logon': + triggerPart = `$trigger = New-ScheduledTaskTrigger -AtLogOn`; + break; + case 'startup': + triggerPart = `$trigger = New-ScheduledTaskTrigger -AtStartup`; + break; + } + + ps = ` +${actionPart} +${triggerPart} +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable +Register-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -Action $action -Trigger $trigger -Settings $settings -Force -ErrorAction Stop | + Select-Object TaskName,State | ConvertTo-Json -Compress`; + break; + } + } + + const result = await runPowerShell(ps, { timeout: 15000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + return { content: [{ type: 'text', text: result.stdout }] }; + }, + ); +} diff --git a/src/tools/service.ts b/src/tools/service.ts new file mode 100644 index 0000000..a69453e --- /dev/null +++ b/src/tools/service.ts @@ -0,0 +1,101 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_service_list (#4), windows_service_control (#5) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerServiceTools(server: McpServer): void { + server.tool( + 'windows_service_list', + 'List Windows services with status, startup type, and description.', + { + filter: z.string().optional().describe('Filter by service name or display name (substring)'), + status: z.enum(['running', 'stopped', 'all']).default('all').describe('Filter by status'), + }, + async ({ filter, status }) => { + const filterClause = filter + ? `| Where-Object { $_.Name -like '*${filter.replace(/'/g, "''") }*' -or $_.DisplayName -like '*${filter.replace(/'/g, "''")}*' }` + : ''; + const statusClause = status === 'running' + ? `| Where-Object { $_.Status -eq 'Running' }` + : status === 'stopped' + ? `| Where-Object { $_.Status -eq 'Stopped' }` + : ''; + + const ps = ` +Get-Service ${filterClause} ${statusClause} | Sort-Object DisplayName | ForEach-Object { + $wmi = Get-CimInstance Win32_Service -Filter "Name='$($_.Name)'" -ErrorAction SilentlyContinue + [PSCustomObject]@{ + Name = $_.Name + DisplayName = $_.DisplayName + Status = $_.Status.ToString() + StartType = $_.StartType.ToString() + Description = if ($wmi) { $wmi.Description } else { '' } + } +} | 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 services found.' }] }; + } + + const services = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = services.map((s: { Name: string; DisplayName: string; Status: string; StartType: string }) => + `${s.Status === 'Running' ? '[RUN]' : '[STP]'} ${s.StartType.padEnd(10)} ${s.Name.padEnd(35).slice(0, 35)} ${s.DisplayName}`, + ); + + const header = `${'State'.padEnd(5)} ${'Startup'.padEnd(10)} ${'Name'.padEnd(35)} Display Name`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(100)}\n${lines.join('\n')}\n\n${services.length} services` }], + }; + }, + ); + + server.tool( + 'windows_service_control', + 'Start, stop, restart, or change startup type of a Windows service.', + { + name: z.string().describe('Service name or display name'), + action: z.enum(['start', 'stop', 'restart', 'enable', 'disable']).describe('Action to perform'), + }, + async ({ name, action }) => { + let ps: string; + + switch (action) { + case 'start': + ps = `Start-Service -Name '${name.replace(/'/g, "''")}' -ErrorAction Stop; Get-Service -Name '${name.replace(/'/g, "''")}' | Select-Object Name,Status,StartType | ConvertTo-Json -Compress`; + break; + case 'stop': + ps = `Stop-Service -Name '${name.replace(/'/g, "''")}' -Force -ErrorAction Stop; Get-Service -Name '${name.replace(/'/g, "''")}' | Select-Object Name,Status,StartType | ConvertTo-Json -Compress`; + break; + case 'restart': + ps = `Restart-Service -Name '${name.replace(/'/g, "''")}' -Force -ErrorAction Stop; Get-Service -Name '${name.replace(/'/g, "''")}' | Select-Object Name,Status,StartType | ConvertTo-Json -Compress`; + break; + case 'enable': + ps = `Set-Service -Name '${name.replace(/'/g, "''")}' -StartupType Automatic -ErrorAction Stop; Get-Service -Name '${name.replace(/'/g, "''")}' | Select-Object Name,Status,StartType | ConvertTo-Json -Compress`; + break; + case 'disable': + ps = `Set-Service -Name '${name.replace(/'/g, "''")}' -StartupType Disabled -ErrorAction Stop; Get-Service -Name '${name.replace(/'/g, "''")}' | Select-Object Name,Status,StartType | 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 }; + } + + const svc = JSON.parse(result.stdout); + return { + content: [{ type: 'text', text: `Service "${svc.Name}": ${action} → Status: ${svc.Status}, StartType: ${svc.StartType}` }], + }; + }, + ); +} diff --git a/src/tools/startup.ts b/src/tools/startup.ts new file mode 100644 index 0000000..e413133 --- /dev/null +++ b/src/tools/startup.ts @@ -0,0 +1,180 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_startup_list (#33), windows_startup_manage (#34) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +export function registerStartupTools(server: McpServer): void { + server.tool( + 'windows_startup_list', + 'List all startup items from registry (Run/RunOnce), startup folder, and scheduled logon tasks.', + {}, + async () => { + const ps = ` +$items = [System.Collections.Generic.List[PSObject]]::new() + +# Registry: HKCU Run +$regPaths = @( + @{ Path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; Scope = 'User'; Source = 'Registry Run' } + @{ Path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce'; Scope = 'User'; Source = 'Registry RunOnce' } + @{ Path = 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; Scope = 'System'; Source = 'Registry Run' } + @{ Path = 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce'; Scope = 'System'; Source = 'Registry RunOnce' } +) + +foreach ($rp in $regPaths) { + if (Test-Path $rp.Path) { + $props = Get-ItemProperty -Path $rp.Path -ErrorAction SilentlyContinue + if ($props) { + $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object { + $items.Add([PSCustomObject]@{ + Name = $_.Name + Command = $_.Value + Source = $rp.Source + Scope = $rp.Scope + Enabled = $true + }) + } + } + } +} + +# Startup folder +$userStartup = [Environment]::GetFolderPath('Startup') +$commonStartup = [Environment]::GetFolderPath('CommonStartup') +foreach ($folder in @(@{Path=$userStartup;Scope='User'}, @{Path=$commonStartup;Scope='System'})) { + if (Test-Path $folder.Path) { + Get-ChildItem -Path $folder.Path -File -ErrorAction SilentlyContinue | ForEach-Object { + $items.Add([PSCustomObject]@{ + Name = $_.BaseName + Command = $_.FullName + Source = 'Startup Folder' + Scope = $folder.Scope + Enabled = $true + }) + } + } +} + +# Scheduled tasks at logon +Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { + $_.Triggers | Where-Object { $_ -is [CimInstance] -and $_.CimClass.CimClassName -eq 'MSFT_TaskLogonTrigger' } +} | ForEach-Object { + $items.Add([PSCustomObject]@{ + Name = $_.TaskName + Command = ($_.Actions | ForEach-Object { $_.Execute }) -join ' ' + Source = 'Task Scheduler (Logon)' + Scope = 'System' + Enabled = $_.State -eq 'Ready' + }) +} + +$items | ConvertTo-Json -Depth 3 -Compress`; + + const result = await runPowerShell(ps, { timeout: 20000 }); + if (result.exitCode !== 0) { + return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true }; + } + + if (!result.stdout) { + return { content: [{ type: 'text', text: 'No startup items found.' }] }; + } + + const items = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = items.map((i: { Name: string; Command: string; Source: string; Scope: string; Enabled: boolean }) => { + const status = i.Enabled ? '[ON] ' : '[OFF]'; + return `${status} ${i.Scope.padEnd(6)} ${i.Source.padEnd(24)} ${i.Name.padEnd(30).slice(0, 30)} ${(i.Command || '').slice(0, 50)}`; + }); + + const header = `State ${'Scope'.padEnd(6)} ${'Source'.padEnd(24)} ${'Name'.padEnd(30)} Command`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(110)}\n${lines.join('\n')}\n\n${items.length} startup items` }], + }; + }, + ); + + server.tool( + 'windows_startup_manage', + 'Add, remove, enable, or disable a startup item.', + { + action: z.enum(['add', 'remove', 'enable', 'disable']).describe('Action'), + name: z.string().describe('Startup item name'), + command: z.string().optional().describe('Command to run at startup (for add)'), + location: z.enum(['registry', 'startup_folder']).default('registry').describe('Where to add (for add)'), + }, + async ({ action, name, command, location }) => { + let ps: string; + + switch (action) { + case 'add': + if (!command) { + return { content: [{ type: 'text', text: 'Add requires a command.' }], isError: true }; + } + if (location === 'startup_folder') { + ps = ` +$startupPath = [Environment]::GetFolderPath('Startup') +$shortcutPath = Join-Path $startupPath '${name.replace(/'/g, "''")}.lnk' +$ws = New-Object -ComObject WScript.Shell +$sc = $ws.CreateShortcut($shortcutPath) +$sc.TargetPath = '${command.replace(/'/g, "''")}' +$sc.Save() +"Added startup shortcut: $shortcutPath"`; + } else { + ps = ` +Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name '${name.replace(/'/g, "''")}' -Value '${command.replace(/'/g, "''")}' -ErrorAction Stop +"Added to HKCU Run: ${name}"`; + } + break; + + case 'remove': + ps = ` +$removed = $false +# Try registry +$regPath = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' +if ((Get-ItemProperty -Path $regPath -Name '${name.replace(/'/g, "''")}' -ErrorAction SilentlyContinue)) { + Remove-ItemProperty -Path $regPath -Name '${name.replace(/'/g, "''")}' -ErrorAction Stop + $removed = $true +} +# Try startup folder +$startupPath = [Environment]::GetFolderPath('Startup') +$lnk = Join-Path $startupPath '${name.replace(/'/g, "''")}.lnk' +if (Test-Path $lnk) { Remove-Item $lnk -Force; $removed = $true } +if ($removed) { "Removed: ${name}" } else { "Not found: ${name}" }`; + break; + + case 'disable': + ps = ` +$regPath = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' +$val = (Get-ItemProperty -Path $regPath -Name '${name.replace(/'/g, "''")}' -ErrorAction SilentlyContinue).'${name.replace(/'/g, "''")}' +if ($val) { + Remove-ItemProperty -Path $regPath -Name '${name.replace(/'/g, "''")}' -ErrorAction Stop + $disabledPath = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run_Disabled' + if (-not (Test-Path $disabledPath)) { New-Item -Path $disabledPath -Force | Out-Null } + Set-ItemProperty -Path $disabledPath -Name '${name.replace(/'/g, "''")}' -Value $val + "Disabled: ${name}" +} else { "Not found in registry Run: ${name}" }`; + break; + + case 'enable': + ps = ` +$disabledPath = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run_Disabled' +$val = (Get-ItemProperty -Path $disabledPath -Name '${name.replace(/'/g, "''")}' -ErrorAction SilentlyContinue).'${name.replace(/'/g, "''")}' +if ($val) { + Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name '${name.replace(/'/g, "''")}' -Value $val + Remove-ItemProperty -Path $disabledPath -Name '${name.replace(/'/g, "''")}' -ErrorAction SilentlyContinue + "Enabled: ${name}" +} else { "Not found in disabled items: ${name}" }`; + break; + } + + const result = await runPowerShell(ps, { timeout: 10000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +} diff --git a/src/tools/system.ts b/src/tools/system.ts new file mode 100644 index 0000000..89b4877 --- /dev/null +++ b/src/tools/system.ts @@ -0,0 +1,99 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_system_info (#18) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { runPowerShell } from '../shell.js'; + +export function registerSystemTools(server: McpServer): void { + server.tool( + 'windows_system_info', + 'Get comprehensive system information: OS, CPU, RAM, disk, network, uptime.', + {}, + async () => { + const ps = ` +$os = Get-CimInstance Win32_OperatingSystem +$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1 +$cs = Get-CimInstance Win32_ComputerSystem +$disks = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object { + [PSCustomObject]@{ + Drive = $_.DeviceID + Label = $_.VolumeName + FileSystem = $_.FileSystem + TotalGB = [math]::Round($_.Size / 1GB, 1) + FreeGB = [math]::Round($_.FreeSpace / 1GB, 1) + UsedPct = if ($_.Size -gt 0) { [math]::Round(($_.Size - $_.FreeSpace) / $_.Size * 100, 1) } else { 0 } + } +} +$adapters = Get-NetAdapter -Physical -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq 'Up' } | ForEach-Object { + $ip = (Get-NetIPAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue).IPAddress + [PSCustomObject]@{ + Name = $_.Name + Speed = $_.LinkSpeed + IP = $ip + MAC = $_.MacAddress + } +} +$uptime = (Get-Date) - $os.LastBootUpTime + +[PSCustomObject]@{ + OS = "$($os.Caption) $($os.Version) Build $($os.BuildNumber)" + Edition = $os.OperatingSystemSKU + Architecture = $os.OSArchitecture + Hostname = $env:COMPUTERNAME + Username = $env:USERNAME + Domain = $cs.Domain + CPU = "$($cpu.Name)" + CPUCores = "$($cpu.NumberOfCores) cores / $($cpu.NumberOfLogicalProcessors) threads" + CPUUsage = "$([math]::Round($cpu.LoadPercentage, 0))%" + RAMTotalGB = [math]::Round($cs.TotalPhysicalMemory / 1GB, 1) + RAMAvailGB = [math]::Round($os.FreePhysicalMemory / 1MB, 1) + RAMUsedPct = [math]::Round(($cs.TotalPhysicalMemory - $os.FreePhysicalMemory * 1KB) / $cs.TotalPhysicalMemory * 100, 1) + Disks = $disks + Network = $adapters + Uptime = "$($uptime.Days)d $($uptime.Hours)h $($uptime.Minutes)m" +} | 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 diskLines = (Array.isArray(info.Disks) ? info.Disks : [info.Disks]) + .filter(Boolean) + .map((d: { Drive: string; Label: string; TotalGB: number; FreeGB: number; UsedPct: number }) => + ` ${d.Drive} ${d.Label || ''} — ${d.FreeGB}/${d.TotalGB} GB free (${d.UsedPct}% used)`, + ); + const netLines = (Array.isArray(info.Network) ? info.Network : [info.Network]) + .filter(Boolean) + .map((n: { Name: string; IP: string; Speed: string }) => + ` ${n.Name} — ${n.IP || 'no IP'} (${n.Speed})`, + ); + + const text = [ + `OS: ${info.OS}`, + `Architecture: ${info.Architecture}`, + `Host: ${info.Hostname} (${info.Domain})`, + `User: ${info.Username}`, + `Uptime: ${info.Uptime}`, + ``, + `CPU: ${info.CPU}`, + `Cores: ${info.CPUCores}`, + `CPU Usage: ${info.CPUUsage}`, + ``, + `RAM: ${info.RAMAvailGB}/${info.RAMTotalGB} GB available (${info.RAMUsedPct}% used)`, + ``, + `Disks:`, + ...diskLines, + ``, + `Network:`, + ...netLines, + ].join('\n'); + + return { content: [{ type: 'text', text }] }; + }, + ); +} diff --git a/src/tools/terminal.ts b/src/tools/terminal.ts new file mode 100644 index 0000000..d0d28d4 --- /dev/null +++ b/src/tools/terminal.ts @@ -0,0 +1,117 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tool: windows_terminal_session — Persistent interactive terminals (#35) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { + startSession, + sendToSession, + readSessionOutput, + listSessions, + terminateSession, +} from '../shell.js'; + +export function registerTerminalTools(server: McpServer): void { + server.tool( + 'windows_terminal_start', + 'Start a persistent interactive terminal session (PowerShell, cmd, bash, python, node, wsl).', + { + shell: z.enum(['pwsh', 'cmd', 'bash', 'python', 'node', 'wsl']).default('pwsh').describe('Shell type'), + }, + async ({ shell }) => { + const session = startSession(shell); + // Give the shell a moment to start and produce initial output + await new Promise(resolve => setTimeout(resolve, 500)); + return { + content: [{ + type: 'text', + text: `Session started: PID ${session.pid} (${session.shell})\nUse windows_terminal_send to send commands, windows_terminal_read to read output.`, + }], + }; + }, + ); + + server.tool( + 'windows_terminal_send', + 'Send input to a running interactive terminal session.', + { + pid: z.number().describe('Session PID'), + input: z.string().describe('Text to send to the session'), + }, + async ({ pid, input }) => { + try { + sendToSession(pid, input); + // Wait for output to arrive + await new Promise(resolve => setTimeout(resolve, 1000)); + const lines = readSessionOutput(pid, -30); + return { + content: [{ + type: 'text', + text: lines.join('\n') || '(no output yet)', + }], + }; + } catch (err) { + return { content: [{ type: 'text', text: `Error: ${err}` }], isError: true }; + } + }, + ); + + server.tool( + 'windows_terminal_read', + 'Read output from a terminal session with pagination. Use negative offset to read from the end.', + { + pid: z.number().describe('Session PID'), + offset: z.number().default(0).describe('Line offset (negative = from end)'), + length: z.number().optional().describe('Number of lines to return'), + }, + async ({ pid, offset, length }) => { + try { + const lines = readSessionOutput(pid, offset, length); + return { + content: [{ + type: 'text', + text: lines.join('\n') || '(no output)', + }], + }; + } catch (err) { + return { content: [{ type: 'text', text: `Error: ${err}` }], isError: true }; + } + }, + ); + + server.tool( + 'windows_terminal_list', + 'List all active terminal sessions.', + {}, + async () => { + const sessions = listSessions(); + if (sessions.length === 0) { + return { content: [{ type: 'text', text: 'No active sessions.' }] }; + } + const lines = sessions.map(s => + `PID ${s.pid} — ${s.shell} — ${s.running ? 'running' : 'ended'} — ${s.lines} lines — started ${s.startedAt}`, + ); + return { content: [{ type: 'text', text: lines.join('\n') }] }; + }, + ); + + server.tool( + 'windows_terminal_kill', + 'Terminate a terminal session by PID.', + { + pid: z.number().describe('Session PID to terminate'), + }, + async ({ pid }) => { + const killed = terminateSession(pid); + return { + content: [{ + type: 'text', + text: killed ? `Session ${pid} terminated.` : `No session with PID ${pid}.`, + }], + }; + }, + ); +} diff --git a/src/tools/window.ts b/src/tools/window.ts new file mode 100644 index 0000000..745b027 --- /dev/null +++ b/src/tools/window.ts @@ -0,0 +1,211 @@ +/* Copyright (C) 2026 Moko Consulting + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Tools: windows_window_list (#14), windows_window_control (#15) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { runPowerShell } from '../shell.js'; + +const WIN32_TYPES = ` +Add-Type @' +using System; +using System.Text; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +public class WindowManager { + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll", CharSet = CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); + [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); + [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool IsZoomed(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); + + public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [StructLayout(LayoutKind.Sequential)] + public struct RECT { public int Left, Top, Right, Bottom; } + + public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); + public static readonly IntPtr HWND_NOTOPMOST = new IntPtr(-2); + public const uint SWP_NOMOVE = 0x0002; + public const uint SWP_NOSIZE = 0x0001; + public const uint WM_CLOSE = 0x0010; +} +'@ -ErrorAction SilentlyContinue +`; + +export function registerWindowTools(server: McpServer): void { + server.tool( + 'windows_window_list', + 'List all visible windows with title, PID, process name, position, size, and state.', + { + filter: z.string().optional().describe('Filter by window title (substring)'), + }, + async ({ filter }) => { + const filterClause = filter + ? `.Where({ $_.Title -like '*${filter.replace(/'/g, "''")}*' })` + : ''; + + const ps = ` +${WIN32_TYPES} +$windows = [System.Collections.Generic.List[PSObject]]::new() +$zOrder = 0 +[WindowManager]::EnumWindows({ + param($hWnd, $lParam) + if (-not [WindowManager]::IsWindowVisible($hWnd)) { return $true } + $len = [WindowManager]::GetWindowTextLength($hWnd) + if ($len -eq 0) { return $true } + $sb = New-Object System.Text.StringBuilder($len + 1) + [WindowManager]::GetWindowText($hWnd, $sb, $sb.Capacity) | Out-Null + $title = $sb.ToString() + if (-not $title) { return $true } + + $pid = [uint32]0 + [WindowManager]::GetWindowThreadProcessId($hWnd, [ref]$pid) | Out-Null + $proc = Get-Process -Id $pid -ErrorAction SilentlyContinue + + $rect = New-Object WindowManager+RECT + [WindowManager]::GetWindowRect($hWnd, [ref]$rect) | Out-Null + + $state = 'Normal' + if ([WindowManager]::IsIconic($hWnd)) { $state = 'Minimized' } + elseif ([WindowManager]::IsZoomed($hWnd)) { $state = 'Maximized' } + + $script:windows.Add([PSCustomObject]@{ + ZOrder = $script:zOrder++ + Title = $title + PID = $pid + Process = if ($proc) { $proc.ProcessName } else { '?' } + X = $rect.Left + Y = $rect.Top + Width = $rect.Right - $rect.Left + Height = $rect.Bottom - $rect.Top + State = $state + }) + return $true +}, [IntPtr]::Zero) | Out-Null + +$windows ${filterClause} | 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 visible windows found.' }] }; + } + + const windows = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)]; + const lines = windows.map((w: { ZOrder: number; Title: string; PID: number; Process: string; X: number; Y: number; Width: number; Height: number; State: string }) => { + const stateIcon = w.State === 'Minimized' ? '[-]' : w.State === 'Maximized' ? '[+]' : '[ ]'; + return `${stateIcon} ${String(w.PID).padStart(6)} ${w.Process.padEnd(20).slice(0, 20)} ${String(w.Width).padStart(5)}x${String(w.Height).padEnd(5)} (${w.X},${w.Y}) ${w.Title.slice(0, 60)}`; + }); + + const header = `Sta ${'PID'.padStart(6)} ${'Process'.padEnd(20)} ${'Size'.padStart(11)} Pos Title`; + return { + content: [{ type: 'text', text: `${header}\n${'─'.repeat(110)}\n${lines.join('\n')}\n\n${windows.length} windows` }], + }; + }, + ); + + server.tool( + 'windows_window_control', + 'Move, resize, minimize, maximize, restore, close, or focus a window.', + { + title: z.string().optional().describe('Window title (substring match)'), + pid: z.number().optional().describe('Process ID'), + action: z.enum(['minimize', 'maximize', 'restore', 'close', 'focus', 'move', 'resize', 'topmost']).describe('Action'), + x: z.number().optional().describe('X position (for move)'), + y: z.number().optional().describe('Y position (for move)'), + width: z.number().optional().describe('Width (for resize)'), + height: z.number().optional().describe('Height (for resize)'), + topmost: z.boolean().optional().describe('Set always-on-top (for topmost action)'), + }, + async ({ title, pid, action, x, y, width, height, topmost }) => { + if (!title && !pid) { + return { content: [{ type: 'text', text: 'Provide either title or pid.' }], isError: true }; + } + + const findWindow = title + ? ` +$target = '${title.replace(/'/g, "''")}' +$hWnd = [IntPtr]::Zero +[WindowManager]::EnumWindows({ + param($h, $l) + $sb = New-Object System.Text.StringBuilder 256 + [WindowManager]::GetWindowText($h, $sb, 256) | Out-Null + if ($sb.ToString() -like "*$target*" -and [WindowManager]::IsWindowVisible($h)) { + $script:hWnd = $h; return $false + } + return $true +}, [IntPtr]::Zero) | Out-Null +if ($hWnd -eq [IntPtr]::Zero) { throw "Window not found: $target" }` + : ` +$proc = Get-Process -Id ${pid} -ErrorAction Stop +$hWnd = $proc.MainWindowHandle +if ($hWnd -eq [IntPtr]::Zero) { throw "Process ${pid} has no visible window" }`; + + let actionCode: string; + switch (action) { + case 'minimize': + actionCode = `[WindowManager]::ShowWindow($hWnd, 6) | Out-Null; "Minimized"`; + break; + case 'maximize': + actionCode = `[WindowManager]::ShowWindow($hWnd, 3) | Out-Null; "Maximized"`; + break; + case 'restore': + actionCode = `[WindowManager]::ShowWindow($hWnd, 9) | Out-Null; "Restored"`; + break; + case 'close': + actionCode = `[WindowManager]::PostMessage($hWnd, [WindowManager]::WM_CLOSE, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null; "Close message sent"`; + break; + case 'focus': + actionCode = `[WindowManager]::ShowWindow($hWnd, 9) | Out-Null; [WindowManager]::SetForegroundWindow($hWnd) | Out-Null; "Focused"`; + break; + case 'move': + if (x === undefined || y === undefined) { + return { content: [{ type: 'text', text: 'Move requires x and y.' }], isError: true }; + } + actionCode = ` +$rect = New-Object WindowManager+RECT +[WindowManager]::GetWindowRect($hWnd, [ref]$rect) | Out-Null +$w = $rect.Right - $rect.Left; $h = $rect.Bottom - $rect.Top +[WindowManager]::MoveWindow($hWnd, ${x}, ${y}, $w, $h, $true) | Out-Null +"Moved to (${x}, ${y})"`; + break; + case 'resize': + if (!width || !height) { + return { content: [{ type: 'text', text: 'Resize requires width and height.' }], isError: true }; + } + actionCode = ` +$rect = New-Object WindowManager+RECT +[WindowManager]::GetWindowRect($hWnd, [ref]$rect) | Out-Null +[WindowManager]::MoveWindow($hWnd, $rect.Left, $rect.Top, ${width}, ${height}, $true) | Out-Null +"Resized to ${width}x${height}"`; + break; + case 'topmost': + const insertAfter = topmost !== false ? '[WindowManager]::HWND_TOPMOST' : '[WindowManager]::HWND_NOTOPMOST'; + actionCode = `[WindowManager]::SetWindowPos($hWnd, ${insertAfter}, 0, 0, 0, 0, [WindowManager]::SWP_NOMOVE -bor [WindowManager]::SWP_NOSIZE) | Out-Null; "Topmost: ${topmost !== false}"`; + break; + } + + const ps = `${WIN32_TYPES}\n${findWindow}\n${actionCode}`; + const result = await runPowerShell(ps, { timeout: 10000 }); + return { + content: [{ type: 'text', text: result.stdout || result.stderr }], + isError: result.exitCode !== 0, + }; + }, + ); +} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index f2062b0..0000000 --- a/src/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* Copyright (C) 2026 Moko Consulting - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: mcp_windows.Types - * INGROUP: mcp_windows - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mcp_windows - * PATH: /src/types.ts - * VERSION: 01.00.00 - * BRIEF: TypeScript type definitions for Windows Desktop MCP server - */ - -/** - * Connection configuration for a single API instance. - * - * Rename and extend these fields to match your target API's auth mechanism: - * - `apiKey` + DOLAPIKEY header (Dolibarr) - * - `apiToken` + Bearer header (Joomla, GitHub) - * - `username`/`password` (Basic auth) - * - `oauth` fields (OAuth2 flows) - */ -export interface ApiConnection { - /** Base URL of the API instance (no trailing slash) */ - baseUrl: string; - /** API key or token for authentication */ - apiKey: string; - /** Skip TLS certificate verification (self-signed certs) */ - insecure?: boolean; -} - -/** - * Top-level configuration supporting multiple named connections. - */ -export interface ApiConfig { - connections: Record; - defaultConnection: string; -} - -/** - * Normalized API response returned by the HTTP client. - */ -export interface ApiResponse { - status: number; - data: unknown; -}