Merge pull request 'feat: implement all 44 MCP tools (v1.0 through v1.4)' (#41) from dev into main
Universal: Cascade Main → Dev / Cascade main → branches (push) Has been cancelled
Universal: Changelog Validation / Validate CHANGELOG.md (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
MCP: Standards Compliance / Secret Scanning (push) Has been cancelled
MCP: Standards Compliance / Repository Structure Validation (push) Has been cancelled
MCP: Standards Compliance / License Header Validation (push) Has been cancelled
MCP: Standards Compliance / Coding Standards Check (push) Has been cancelled
MCP: Standards Compliance / Workflow Configuration Check (push) Has been cancelled
MCP: Standards Compliance / Documentation Quality Check (push) Has been cancelled
MCP: Standards Compliance / README Completeness Check (push) Has been cancelled
MCP: Standards Compliance / Git Repository Hygiene (push) Has been cancelled
MCP: Standards Compliance / File Naming Standards (push) Has been cancelled
MCP: Tool Inventory / inventory (push) Has been cancelled
MCP: Standards Compliance / Line Length Check (push) Has been cancelled
MCP: Standards Compliance / Script Integrity Validation (push) Has been cancelled
MCP: Standards Compliance / Insecure Code Pattern Detection (push) Has been cancelled
MCP: Standards Compliance / File Size Limits (push) Has been cancelled
MCP: Standards Compliance / Dead Code Detection (push) Has been cancelled
MCP: Standards Compliance / Binary File Detection (push) Has been cancelled
MCP: Build & Release / Build, Validate & Release (push) Has been cancelled
MCP: Standards Compliance / TODO/FIXME Tracking (push) Has been cancelled
MCP: Build & Validate / build (20) (push) Has been cancelled
MCP: Build & Validate / build (22) (push) Has been cancelled
MCP: Standards Compliance / Broken Link Detection (push) Has been cancelled
MCP: Standards Compliance / API Documentation Coverage (push) Has been cancelled
MCP: Standards Compliance / Accessibility Check (push) Has been cancelled
MCP: Standards Compliance / Performance Metrics (push) Has been cancelled
MCP: Standards Compliance / Terraform Configuration Validation (push) Has been cancelled
MCP: Standards Compliance / Version Consistency Check (push) Has been cancelled
MCP: Standards Compliance / Code Duplication Detection (push) Has been cancelled
MCP: Standards Compliance / Code Complexity Analysis (push) Has been cancelled
Universal: CodeQL Analysis / Analyze (actions) (push) Has been cancelled
Universal: CodeQL Analysis / Analyze (javascript) (push) Has been cancelled
Universal: CodeQL Analysis / Security Scan Summary (push) Has been cancelled
MCP: Standards Compliance / Unused Dependencies Check (push) Has been cancelled
MCP: Standards Compliance / Dependency Vulnerability Scanning (push) Has been cancelled
MCP: Standards Compliance / Repository Health Check (push) Has been cancelled
MCP: Standards Compliance / Enterprise Readiness Check (push) Has been cancelled
MCP: Standards Compliance / Compliance Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: Cascade Main → Dev / Cascade main → branches (push) Has been cancelled
Universal: Changelog Validation / Validate CHANGELOG.md (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
MCP: Standards Compliance / Secret Scanning (push) Has been cancelled
MCP: Standards Compliance / Repository Structure Validation (push) Has been cancelled
MCP: Standards Compliance / License Header Validation (push) Has been cancelled
MCP: Standards Compliance / Coding Standards Check (push) Has been cancelled
MCP: Standards Compliance / Workflow Configuration Check (push) Has been cancelled
MCP: Standards Compliance / Documentation Quality Check (push) Has been cancelled
MCP: Standards Compliance / README Completeness Check (push) Has been cancelled
MCP: Standards Compliance / Git Repository Hygiene (push) Has been cancelled
MCP: Standards Compliance / File Naming Standards (push) Has been cancelled
MCP: Tool Inventory / inventory (push) Has been cancelled
MCP: Standards Compliance / Line Length Check (push) Has been cancelled
MCP: Standards Compliance / Script Integrity Validation (push) Has been cancelled
MCP: Standards Compliance / Insecure Code Pattern Detection (push) Has been cancelled
MCP: Standards Compliance / File Size Limits (push) Has been cancelled
MCP: Standards Compliance / Dead Code Detection (push) Has been cancelled
MCP: Standards Compliance / Binary File Detection (push) Has been cancelled
MCP: Build & Release / Build, Validate & Release (push) Has been cancelled
MCP: Standards Compliance / TODO/FIXME Tracking (push) Has been cancelled
MCP: Build & Validate / build (20) (push) Has been cancelled
MCP: Build & Validate / build (22) (push) Has been cancelled
MCP: Standards Compliance / Broken Link Detection (push) Has been cancelled
MCP: Standards Compliance / API Documentation Coverage (push) Has been cancelled
MCP: Standards Compliance / Accessibility Check (push) Has been cancelled
MCP: Standards Compliance / Performance Metrics (push) Has been cancelled
MCP: Standards Compliance / Terraform Configuration Validation (push) Has been cancelled
MCP: Standards Compliance / Version Consistency Check (push) Has been cancelled
MCP: Standards Compliance / Code Duplication Detection (push) Has been cancelled
MCP: Standards Compliance / Code Complexity Analysis (push) Has been cancelled
Universal: CodeQL Analysis / Analyze (actions) (push) Has been cancelled
Universal: CodeQL Analysis / Analyze (javascript) (push) Has been cancelled
Universal: CodeQL Analysis / Security Scan Summary (push) Has been cancelled
MCP: Standards Compliance / Unused Dependencies Check (push) Has been cancelled
MCP: Standards Compliance / Dependency Vulnerability Scanning (push) Has been cancelled
MCP: Standards Compliance / Repository Health Check (push) Has been cancelled
MCP: Standards Compliance / Enterprise Readiness Check (push) Has been cancelled
MCP: Standards Compliance / Compliance Summary (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
This commit was merged in pull request #41.
This commit is contained in:
@@ -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 |
|
||||||
-129
@@ -1,129 +0,0 @@
|
|||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
*
|
|
||||||
* 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<string, string>;
|
|
||||||
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<string, string>): Promise<ApiResponse> {
|
|
||||||
return this.request(this.buildUrl(endpoint, params), 'GET');
|
|
||||||
}
|
|
||||||
|
|
||||||
async post(endpoint: string, body?: unknown): Promise<ApiResponse> {
|
|
||||||
return this.request(this.buildUrl(endpoint), 'POST', body);
|
|
||||||
}
|
|
||||||
|
|
||||||
async put(endpoint: string, body: unknown): Promise<ApiResponse> {
|
|
||||||
return this.request(this.buildUrl(endpoint), 'PUT', body);
|
|
||||||
}
|
|
||||||
|
|
||||||
async patch(endpoint: string, body: unknown): Promise<ApiResponse> {
|
|
||||||
return this.request(this.buildUrl(endpoint), 'PATCH', body);
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(endpoint: string): Promise<ApiResponse> {
|
|
||||||
return this.request(this.buildUrl(endpoint), 'DELETE');
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildUrl(endpoint: string, params?: Record<string, string>): 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<ApiResponse> {
|
|
||||||
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<string, string>)['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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
*
|
|
||||||
* 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<ApiConfig> {
|
|
||||||
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<ApiConfig>;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
+58
-172
@@ -1,199 +1,85 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
*
|
|
||||||
* This file is part of a Moko Consulting project.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* FILE INFORMATION
|
* mcp_windows — MCP server for Windows desktop system operations
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
import { z } from 'zod';
|
import { registerExecuteTools } from './tools/execute.js';
|
||||||
import { loadConfig, getConnection } from './config.js';
|
import { registerProcessTools } from './tools/process.js';
|
||||||
import { ApiClient } from './client.js';
|
import { registerAudioTools } from './tools/audio.js';
|
||||||
import type { ApiConfig, ApiResponse } from './types.js';
|
import { registerSystemTools } from './tools/system.js';
|
||||||
|
import { registerTerminalTools } from './tools/terminal.js';
|
||||||
let config: ApiConfig;
|
import { registerFilesystemTools } from './tools/filesystem.js';
|
||||||
|
import { registerProcessKillTools } from './tools/process_kill.js';
|
||||||
function clientFor(connection?: string): ApiClient {
|
import { registerServiceTools } from './tools/service.js';
|
||||||
return new ApiClient(getConnection(config, connection));
|
import { registerAudioAppTools } from './tools/audio_apps.js';
|
||||||
}
|
import { registerPowerTools } from './tools/power.js';
|
||||||
|
import { registerNetworkTools } from './tools/network.js';
|
||||||
function formatResponse(res: ApiResponse): { content: Array<{ type: 'text'; text: string }> } {
|
import { registerDriveTools } from './tools/drives.js';
|
||||||
if (res.status >= 400) {
|
import { registerDisplayTools } from './tools/display.js';
|
||||||
return {
|
import { registerWindowTools } from './tools/window.js';
|
||||||
content: [{ type: 'text' as const, text: `Error: HTTP ${res.status}: ${JSON.stringify(res.data, null, 2)}` }],
|
import { registerClipboardTools } from './tools/clipboard.js';
|
||||||
};
|
import { registerNotificationTools } from './tools/notification.js';
|
||||||
}
|
import { registerSchedulerTools } from './tools/scheduler.js';
|
||||||
return {
|
import { registerRegistryTools } from './tools/registry.js';
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(res.data, null, 2) }],
|
import { registerEnvironmentTools } from './tools/environment.js';
|
||||||
};
|
import { registerStartupTools } from './tools/startup.js';
|
||||||
}
|
import { registerConfigTools } from './tools/config.js';
|
||||||
|
import { registerAppsTools } from './tools/apps.js';
|
||||||
// ── Shared parameter definitions ────────────────────────────────────────
|
import { registerDialogTools } from './tools/dialog.js';
|
||||||
|
import { registerNetstatTools } from './tools/netstat.js';
|
||||||
const ConnectionParam = {
|
import { registerRecycleBinTools } from './tools/recycle_bin.js';
|
||||||
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<string, string> {
|
|
||||||
const q: Record<string, string> = {};
|
|
||||||
if (params.limit !== undefined) q['limit'] = String(params.limit);
|
|
||||||
if (params.page !== undefined) q['page'] = String(params.page);
|
|
||||||
return q;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Server ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const server = new McpServer({
|
const server = new McpServer({
|
||||||
name: 'mcp_windows',
|
name: 'mcp_windows',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════
|
// v1.0 — Core
|
||||||
// ADD YOUR TOOLS BELOW
|
registerExecuteTools(server);
|
||||||
// ════════════════════════════════════════════════════════════════════════
|
registerProcessTools(server);
|
||||||
//
|
registerAudioTools(server);
|
||||||
// Follow this pattern for each tool:
|
registerSystemTools(server);
|
||||||
//
|
registerTerminalTools(server);
|
||||||
// server.tool(
|
registerFilesystemTools(server);
|
||||||
// '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
|
|
||||||
//
|
|
||||||
// ════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
// ── Example: Resources ──────────────────────────────────────────────────
|
// v1.1 — System Control
|
||||||
|
registerProcessKillTools(server);
|
||||||
|
registerServiceTools(server);
|
||||||
|
registerAudioAppTools(server);
|
||||||
|
registerPowerTools(server);
|
||||||
|
registerNetworkTools(server);
|
||||||
|
registerDriveTools(server);
|
||||||
|
|
||||||
server.tool(
|
// v1.2 — Desktop Automation
|
||||||
'example_resources_list',
|
registerDisplayTools(server);
|
||||||
'List resources (EXAMPLE — replace with your API resources)',
|
registerWindowTools(server);
|
||||||
{
|
registerClipboardTools(server);
|
||||||
search: z.string().optional().describe('Search query'),
|
registerNotificationTools(server);
|
||||||
...PaginationParams,
|
|
||||||
...ConnectionParam,
|
|
||||||
},
|
|
||||||
async ({ search, limit, page, connection }) => {
|
|
||||||
const client = clientFor(connection);
|
|
||||||
const params: Record<string, string> = { ...paginationQuery({ limit, page }) };
|
|
||||||
if (search) params['search'] = search;
|
|
||||||
return formatResponse(await client.get('/resources', params));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.tool(
|
// v1.3 — Admin Tools
|
||||||
'example_resource_get',
|
registerSchedulerTools(server);
|
||||||
'Get a single resource by ID (EXAMPLE — replace with your API resources)',
|
registerRegistryTools(server);
|
||||||
{
|
registerEnvironmentTools(server);
|
||||||
id: z.number().describe('Resource ID'),
|
registerStartupTools(server);
|
||||||
...ConnectionParam,
|
registerConfigTools(server);
|
||||||
},
|
|
||||||
async ({ id, connection }) => {
|
|
||||||
const client = clientFor(connection);
|
|
||||||
return formatResponse(await client.get(`/resources/${id}`));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.tool(
|
// v1.4 — Advanced
|
||||||
'example_resource_create',
|
registerAppsTools(server);
|
||||||
'Create a new resource (EXAMPLE — replace with your API resources)',
|
registerDialogTools(server);
|
||||||
{
|
registerNetstatTools(server);
|
||||||
name: z.string().describe('Resource name'),
|
registerRecycleBinTools(server);
|
||||||
description: z.string().optional().describe('Resource description'),
|
|
||||||
...ConnectionParam,
|
|
||||||
},
|
|
||||||
async ({ name, description, connection }) => {
|
|
||||||
const client = clientFor(connection);
|
|
||||||
const body: Record<string, unknown> = { 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 ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
config = await loadConfig();
|
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
|
process.stderr.write('mcp_windows: server started\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
process.stderr.write(`Fatal: ${err}\n`);
|
process.stderr.write(`mcp_windows: fatal error: ${err}\n`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
+247
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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<ShellResult> {
|
||||||
|
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<T = unknown>(
|
||||||
|
command: string,
|
||||||
|
options: { timeout?: number; cwd?: string } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
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<ShellResult> {
|
||||||
|
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<number, TerminalSession>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Tool: windows_installed_apps (#19)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { runPowerShell } from '../shell.js';
|
||||||
|
|
||||||
|
export function registerAppsTools(server: McpServer): void {
|
||||||
|
server.tool(
|
||||||
|
'windows_installed_apps',
|
||||||
|
'List installed applications from registry and Microsoft Store.',
|
||||||
|
{
|
||||||
|
filter: z.string().optional().describe('Filter by app name (substring)'),
|
||||||
|
sort: z.enum(['name', 'date', 'size']).default('name').describe('Sort order'),
|
||||||
|
limit: z.number().default(50).describe('Max results'),
|
||||||
|
},
|
||||||
|
async ({ filter, sort, limit }) => {
|
||||||
|
const filterClause = filter
|
||||||
|
? `| Where-Object { $_.DisplayName -like '*${filter.replace(/'/g, "''")}*' }`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const sortClause = sort === 'date'
|
||||||
|
? '| Sort-Object InstallDate -Descending'
|
||||||
|
: sort === 'size'
|
||||||
|
? '| Sort-Object { [int]$_.EstimatedSize } -Descending'
|
||||||
|
: '| Sort-Object DisplayName';
|
||||||
|
|
||||||
|
const ps = `
|
||||||
|
$regPaths = @(
|
||||||
|
'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
|
||||||
|
'HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
|
||||||
|
'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'
|
||||||
|
)
|
||||||
|
|
||||||
|
$apps = $regPaths | ForEach-Object {
|
||||||
|
Get-ItemProperty -Path $_ -ErrorAction SilentlyContinue
|
||||||
|
} | Where-Object { $_.DisplayName } ${filterClause} ${sortClause} |
|
||||||
|
Select-Object -First ${limit} |
|
||||||
|
ForEach-Object {
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = $_.DisplayName
|
||||||
|
Version = $_.DisplayVersion
|
||||||
|
Publisher = $_.Publisher
|
||||||
|
InstallDate = $_.InstallDate
|
||||||
|
SizeMB = if ($_.EstimatedSize) { [math]::Round($_.EstimatedSize / 1024, 1) } else { $null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$apps | ConvertTo-Json -Depth 3 -Compress`;
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 20000 });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.stdout) {
|
||||||
|
return { content: [{ type: 'text', text: 'No apps found.' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const apps = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)];
|
||||||
|
const lines = apps.map((a: { Name: string; Version: string; Publisher: string; InstallDate: string; SizeMB: number | null }) => {
|
||||||
|
const size = a.SizeMB ? `${String(a.SizeMB).padStart(8)} MB` : ' ';
|
||||||
|
const date = a.InstallDate || ' ';
|
||||||
|
return `${(a.Name || '').padEnd(45).slice(0, 45)} ${(a.Version || '').padEnd(15).slice(0, 15)} ${date.padEnd(10)} ${size} ${(a.Publisher || '').slice(0, 25)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = `${'Name'.padEnd(45)} ${'Version'.padEnd(15)} ${'Installed'.padEnd(10)} ${'Size'.padStart(11)} Publisher`;
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `${header}\n${'─'.repeat(115)}\n${lines.join('\n')}\n\n${apps.length} applications` }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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)' : ''}`,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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.` }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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 };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Tool: windows_mcp_config (#40)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
const CONFIG_PATH = resolve(homedir(), '.mcp_windows.json');
|
||||||
|
|
||||||
|
interface McpConfig {
|
||||||
|
blockedCommands: string[];
|
||||||
|
allowedDirectories: string[];
|
||||||
|
outputLineLimit: number;
|
||||||
|
commandTimeout: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: McpConfig = {
|
||||||
|
blockedCommands: ['Format-Volume', 'Clear-Disk', 'Remove-Partition'],
|
||||||
|
allowedDirectories: [],
|
||||||
|
outputLineLimit: 5000,
|
||||||
|
commandTimeout: 30000,
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentConfig: McpConfig | null = null;
|
||||||
|
|
||||||
|
async function loadConfig(): Promise<McpConfig> {
|
||||||
|
if (currentConfig) return currentConfig;
|
||||||
|
try {
|
||||||
|
const raw = await readFile(CONFIG_PATH, 'utf-8');
|
||||||
|
currentConfig = { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
|
||||||
|
} catch {
|
||||||
|
currentConfig = { ...DEFAULT_CONFIG };
|
||||||
|
}
|
||||||
|
return currentConfig!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig(config: McpConfig): Promise<void> {
|
||||||
|
await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
|
||||||
|
currentConfig = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerConfigTools(server: McpServer): void {
|
||||||
|
server.tool(
|
||||||
|
'windows_mcp_config',
|
||||||
|
'Get or set mcp_windows configuration (blocked commands, allowed directories, output limits).',
|
||||||
|
{
|
||||||
|
action: z.enum(['get', 'set']).default('get').describe('Get or set config'),
|
||||||
|
key: z.string().optional().describe('Config key to set (blockedCommands, allowedDirectories, outputLineLimit, commandTimeout)'),
|
||||||
|
value: z.string().optional().describe('Value to set (JSON for arrays, number for limits)'),
|
||||||
|
},
|
||||||
|
async ({ action, key, value }) => {
|
||||||
|
const config = await loadConfig();
|
||||||
|
|
||||||
|
if (action === 'get') {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: [
|
||||||
|
`mcp_windows configuration (${CONFIG_PATH}):`,
|
||||||
|
``,
|
||||||
|
`Blocked commands: ${config.blockedCommands.join(', ') || '(none)'}`,
|
||||||
|
`Allowed directories: ${config.allowedDirectories.length > 0 ? config.allowedDirectories.join(', ') : '(unrestricted)'}`,
|
||||||
|
`Output line limit: ${config.outputLineLimit}`,
|
||||||
|
`Command timeout: ${config.commandTimeout}ms`,
|
||||||
|
].join('\n'),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!key || value === undefined) {
|
||||||
|
return { content: [{ type: 'text', text: 'Set requires key and value.' }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'blockedCommands':
|
||||||
|
case 'allowedDirectories':
|
||||||
|
try {
|
||||||
|
(config as unknown as Record<string, unknown>)[key] = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return { content: [{ type: 'text', text: `Value must be a JSON array (e.g. ["cmd1","cmd2"])` }], isError: true };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'outputLineLimit':
|
||||||
|
case 'commandTimeout': {
|
||||||
|
const num = Number(value);
|
||||||
|
if (isNaN(num) || num < 0) {
|
||||||
|
return { content: [{ type: 'text', text: 'Value must be a positive number.' }], isError: true };
|
||||||
|
}
|
||||||
|
(config as unknown as Record<string, unknown>)[key] = num;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { content: [{ type: 'text', text: `Unknown key: ${key}. Valid: blockedCommands, allowedDirectories, outputLineLimit, commandTimeout` }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveConfig(config);
|
||||||
|
return { content: [{ type: 'text', text: `Set ${key} = ${value}` }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Tool: windows_dialog (#21)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { runPowerShell } from '../shell.js';
|
||||||
|
|
||||||
|
export function registerDialogTools(server: McpServer): void {
|
||||||
|
server.tool(
|
||||||
|
'windows_dialog',
|
||||||
|
'Show system dialogs: message box, input prompt, file/folder picker.',
|
||||||
|
{
|
||||||
|
type: z.enum(['message', 'input', 'file_open', 'file_save', 'folder']).describe('Dialog type'),
|
||||||
|
title: z.string().default('mcp_windows').describe('Dialog title'),
|
||||||
|
message: z.string().optional().describe('Message text (for message/input)'),
|
||||||
|
buttons: z.enum(['ok', 'okcancel', 'yesno', 'yesnocancel']).default('ok').describe('Buttons (for message)'),
|
||||||
|
filter: z.string().optional().describe('File filter (for file dialogs, e.g. "Text files|*.txt|All files|*.*")'),
|
||||||
|
default_path: z.string().optional().describe('Default path/filename'),
|
||||||
|
},
|
||||||
|
async ({ type, title, message, buttons, filter, default_path }) => {
|
||||||
|
let ps: string;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'message': {
|
||||||
|
const btnMap: Record<string, number> = { ok: 0, okcancel: 1, yesno: 4, yesnocancel: 3 };
|
||||||
|
ps = `
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
$result = [System.Windows.Forms.MessageBox]::Show('${(message || '').replace(/'/g, "''")}', '${title.replace(/'/g, "''")}', ${btnMap[buttons]})
|
||||||
|
$result.ToString()`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'input':
|
||||||
|
ps = `
|
||||||
|
Add-Type -AssemblyName Microsoft.VisualBasic
|
||||||
|
$result = [Microsoft.VisualBasic.Interaction]::InputBox('${(message || 'Enter value:').replace(/'/g, "''")}', '${title.replace(/'/g, "''")}', '${(default_path || '').replace(/'/g, "''")}')
|
||||||
|
if ($result) { $result } else { '__CANCELLED__' }`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'file_open':
|
||||||
|
ps = `
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
$dlg = New-Object System.Windows.Forms.OpenFileDialog
|
||||||
|
$dlg.Title = '${title.replace(/'/g, "''")}'
|
||||||
|
${filter ? `$dlg.Filter = '${filter.replace(/'/g, "''")}'` : ''}
|
||||||
|
${default_path ? `$dlg.InitialDirectory = '${default_path.replace(/'/g, "''")}'` : ''}
|
||||||
|
$dlg.Multiselect = $false
|
||||||
|
if ($dlg.ShowDialog() -eq 'OK') { $dlg.FileName } else { '__CANCELLED__' }`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'file_save':
|
||||||
|
ps = `
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
$dlg = New-Object System.Windows.Forms.SaveFileDialog
|
||||||
|
$dlg.Title = '${title.replace(/'/g, "''")}'
|
||||||
|
${filter ? `$dlg.Filter = '${filter.replace(/'/g, "''")}'` : ''}
|
||||||
|
${default_path ? `$dlg.FileName = '${default_path.replace(/'/g, "''")}'` : ''}
|
||||||
|
if ($dlg.ShowDialog() -eq 'OK') { $dlg.FileName } else { '__CANCELLED__' }`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'folder':
|
||||||
|
ps = `
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
|
||||||
|
$dlg.Description = '${(message || title).replace(/'/g, "''")}'
|
||||||
|
${default_path ? `$dlg.SelectedPath = '${default_path.replace(/'/g, "''")}'` : ''}
|
||||||
|
if ($dlg.ShowDialog() -eq 'OK') { $dlg.SelectedPath } else { '__CANCELLED__' }`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 120000 }); // Long timeout — user interaction
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = result.stdout.trim();
|
||||||
|
if (output === '__CANCELLED__') {
|
||||||
|
return { content: [{ type: 'text', text: 'Dialog cancelled by user.' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: [{ type: 'text', text: output }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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.` }] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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) + ']';
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Tools: windows_env_get (#31), windows_env_set (#32)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { runPowerShell } from '../shell.js';
|
||||||
|
|
||||||
|
export function registerEnvironmentTools(server: McpServer): void {
|
||||||
|
server.tool(
|
||||||
|
'windows_env_get',
|
||||||
|
'Get environment variables. Can retrieve a specific variable or list all (user, system, or both).',
|
||||||
|
{
|
||||||
|
name: z.string().optional().describe('Variable name (omit to list all)'),
|
||||||
|
scope: z.enum(['user', 'system', 'both']).default('both').describe('Variable scope'),
|
||||||
|
},
|
||||||
|
async ({ name, scope }) => {
|
||||||
|
if (name) {
|
||||||
|
if (name.toUpperCase() === 'PATH') {
|
||||||
|
const ps = `
|
||||||
|
$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||||
|
$sysPath = [Environment]::GetEnvironmentVariable('PATH', 'Machine')
|
||||||
|
[PSCustomObject]@{
|
||||||
|
UserPATH = ($userPath -split ';' | Where-Object { $_ })
|
||||||
|
SystemPATH = ($sysPath -split ';' | Where-Object { $_ })
|
||||||
|
} | ConvertTo-Json -Depth 3 -Compress`;
|
||||||
|
const result = await runPowerShell(ps, { timeout: 10000 });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
|
||||||
|
}
|
||||||
|
const p = JSON.parse(result.stdout);
|
||||||
|
const userPaths = Array.isArray(p.UserPATH) ? p.UserPATH : [p.UserPATH].filter(Boolean);
|
||||||
|
const sysPaths = Array.isArray(p.SystemPATH) ? p.SystemPATH : [p.SystemPATH].filter(Boolean);
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `System PATH (${sysPaths.length} entries):\n${sysPaths.map((p: string) => ` ${p}`).join('\n')}\n\nUser PATH (${userPaths.length} entries):\n${userPaths.map((p: string) => ` ${p}`).join('\n')}`,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ps = `
|
||||||
|
$user = [Environment]::GetEnvironmentVariable('${name.replace(/'/g, "''")}', 'User')
|
||||||
|
$sys = [Environment]::GetEnvironmentVariable('${name.replace(/'/g, "''")}', 'Machine')
|
||||||
|
$proc = [Environment]::GetEnvironmentVariable('${name.replace(/'/g, "''")}', 'Process')
|
||||||
|
[PSCustomObject]@{ Name = '${name.replace(/'/g, "''")}'; User = $user; System = $sys; Process = $proc } | ConvertTo-Json -Compress`;
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 10000 });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
|
||||||
|
}
|
||||||
|
const v = JSON.parse(result.stdout);
|
||||||
|
const lines = [`${v.Name}:`];
|
||||||
|
if (v.System !== null) lines.push(` System: ${v.System}`);
|
||||||
|
if (v.User !== null) lines.push(` User: ${v.User}`);
|
||||||
|
if (v.Process !== null && v.Process !== v.System && v.Process !== v.User) lines.push(` Process: ${v.Process}`);
|
||||||
|
if (v.System === null && v.User === null) lines.push(' (not set)');
|
||||||
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all
|
||||||
|
const scopes = scope === 'both' ? ['User', 'Machine'] : [scope === 'user' ? 'User' : 'Machine'];
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
for (const s of scopes) {
|
||||||
|
const ps = `[Environment]::GetEnvironmentVariables('${s}').GetEnumerator() | Sort-Object Name | ForEach-Object { [PSCustomObject]@{ Name = $_.Name; Value = $_.Value } } | ConvertTo-Json -Depth 3 -Compress`;
|
||||||
|
const result = await runPowerShell(ps, { timeout: 10000 });
|
||||||
|
if (result.exitCode !== 0) continue;
|
||||||
|
if (!result.stdout) continue;
|
||||||
|
|
||||||
|
const vars = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)];
|
||||||
|
const lines = vars.map((v: { Name: string; Value: string }) =>
|
||||||
|
` ${v.Name.padEnd(30)} ${(v.Value || '').slice(0, 60)}`,
|
||||||
|
);
|
||||||
|
parts.push(`${s} Variables (${vars.length}):\n${lines.join('\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: [{ type: 'text', text: parts.join('\n\n') }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'windows_env_set',
|
||||||
|
'Set or remove a persistent environment variable (user or system scope).',
|
||||||
|
{
|
||||||
|
name: z.string().describe('Variable name'),
|
||||||
|
value: z.string().optional().describe('Value to set (omit with action=remove to delete)'),
|
||||||
|
scope: z.enum(['user', 'system']).default('user').describe('Variable scope'),
|
||||||
|
action: z.enum(['set', 'remove', 'append_path', 'prepend_path']).default('set').describe('Action'),
|
||||||
|
},
|
||||||
|
async ({ name, value, scope, action }) => {
|
||||||
|
const target = scope === 'user' ? 'User' : 'Machine';
|
||||||
|
let ps: string;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'set':
|
||||||
|
if (!value) {
|
||||||
|
return { content: [{ type: 'text', text: 'Set requires a value.' }], isError: true };
|
||||||
|
}
|
||||||
|
ps = `[Environment]::SetEnvironmentVariable('${name.replace(/'/g, "''")}', '${value.replace(/'/g, "''")}', '${target}'); "Set ${name}=${value} (${target})"`;
|
||||||
|
break;
|
||||||
|
case 'remove':
|
||||||
|
ps = `[Environment]::SetEnvironmentVariable('${name.replace(/'/g, "''")}', $null, '${target}'); "Removed ${name} (${target})"`;
|
||||||
|
break;
|
||||||
|
case 'append_path':
|
||||||
|
if (!value) {
|
||||||
|
return { content: [{ type: 'text', text: 'append_path requires a value.' }], isError: true };
|
||||||
|
}
|
||||||
|
ps = `
|
||||||
|
$current = [Environment]::GetEnvironmentVariable('PATH', '${target}')
|
||||||
|
$entries = $current -split ';' | Where-Object { $_ }
|
||||||
|
if ('${value.replace(/'/g, "''")}' -notin $entries) {
|
||||||
|
$new = ($entries + '${value.replace(/'/g, "''")}') -join ';'
|
||||||
|
[Environment]::SetEnvironmentVariable('PATH', $new, '${target}')
|
||||||
|
"Appended '${value}' to ${target} PATH"
|
||||||
|
} else {
|
||||||
|
"'${value}' already in ${target} PATH"
|
||||||
|
}`;
|
||||||
|
break;
|
||||||
|
case 'prepend_path':
|
||||||
|
if (!value) {
|
||||||
|
return { content: [{ type: 'text', text: 'prepend_path requires a value.' }], isError: true };
|
||||||
|
}
|
||||||
|
ps = `
|
||||||
|
$current = [Environment]::GetEnvironmentVariable('PATH', '${target}')
|
||||||
|
$entries = $current -split ';' | Where-Object { $_ }
|
||||||
|
if ('${value.replace(/'/g, "''")}' -notin $entries) {
|
||||||
|
$new = ('${value.replace(/'/g, "''")}' + ';' + ($entries -join ';'))
|
||||||
|
[Environment]::SetEnvironmentVariable('PATH', $new, '${target}')
|
||||||
|
"Prepended '${value}' to ${target} PATH"
|
||||||
|
} else {
|
||||||
|
"'${value}' already in ${target} PATH"
|
||||||
|
}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 10000 });
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: result.stdout || result.stderr }],
|
||||||
|
isError: result.exitCode !== 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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<string, string> = {
|
||||||
|
'.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.',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Tool: windows_network_connections (#23)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { runPowerShell } from '../shell.js';
|
||||||
|
|
||||||
|
export function registerNetstatTools(server: McpServer): void {
|
||||||
|
server.tool(
|
||||||
|
'windows_network_connections',
|
||||||
|
'List active TCP/UDP network connections (like netstat). Shows local/remote address, state, and owning process.',
|
||||||
|
{
|
||||||
|
state: z.enum(['all', 'listen', 'established', 'time_wait', 'close_wait']).default('all').describe('Filter by state'),
|
||||||
|
port: z.number().optional().describe('Filter by port number'),
|
||||||
|
process_name: z.string().optional().describe('Filter by process name'),
|
||||||
|
limit: z.number().default(50).describe('Max results'),
|
||||||
|
},
|
||||||
|
async ({ state, port, process_name, limit }) => {
|
||||||
|
const stateMap: Record<string, string> = {
|
||||||
|
listen: "| Where-Object { $_.State -eq 'Listen' }",
|
||||||
|
established: "| Where-Object { $_.State -eq 'Established' }",
|
||||||
|
time_wait: "| Where-Object { $_.State -eq 'TimeWait' }",
|
||||||
|
close_wait: "| Where-Object { $_.State -eq 'CloseWait' }",
|
||||||
|
all: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const portFilter = port
|
||||||
|
? `| Where-Object { $_.LocalPort -eq ${port} -or $_.RemotePort -eq ${port} }`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const procFilter = process_name
|
||||||
|
? `| Where-Object { $procName -like '*${process_name.replace(/'/g, "''")}*' }`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const ps = `
|
||||||
|
Get-NetTCPConnection -ErrorAction SilentlyContinue ${stateMap[state]} ${portFilter} | ForEach-Object {
|
||||||
|
$proc = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
|
||||||
|
$procName = if ($proc) { $proc.ProcessName } else { '?' }
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Proto = 'TCP'
|
||||||
|
LocalAddr = "$($_.LocalAddress):$($_.LocalPort)"
|
||||||
|
RemoteAddr = "$($_.RemoteAddress):$($_.RemotePort)"
|
||||||
|
State = $_.State.ToString()
|
||||||
|
PID = $_.OwningProcess
|
||||||
|
Process = $procName
|
||||||
|
}
|
||||||
|
} ${procFilter} | Select-Object -First ${limit} | ConvertTo-Json -Depth 3 -Compress`;
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 15000 });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.stdout) {
|
||||||
|
return { content: [{ type: 'text', text: 'No connections found.' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conns = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)];
|
||||||
|
const lines = conns.map((c: { Proto: string; LocalAddr: string; RemoteAddr: string; State: string; PID: number; Process: string }) =>
|
||||||
|
`${c.Proto} ${c.LocalAddr.padEnd(22)} ${c.RemoteAddr.padEnd(22)} ${c.State.padEnd(12)} ${String(c.PID).padStart(6)} ${c.Process}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = `Proto ${'Local Address'.padEnd(22)} ${'Remote Address'.padEnd(22)} ${'State'.padEnd(12)} ${'PID'.padStart(6)} Process`;
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `${header}\n${'─'.repeat(100)}\n${lines.join('\n')}\n\n${conns.length} connections` }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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') }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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<string, string> = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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`,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Tool: windows_recycle_bin (#26)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { runPowerShell } from '../shell.js';
|
||||||
|
|
||||||
|
export function registerRecycleBinTools(server: McpServer): void {
|
||||||
|
server.tool(
|
||||||
|
'windows_recycle_bin',
|
||||||
|
'Manage the Recycle Bin: list items, get size, restore items, or empty.',
|
||||||
|
{
|
||||||
|
action: z.enum(['list', 'size', 'empty', 'restore']).default('list').describe('Action'),
|
||||||
|
filter: z.string().optional().describe('Filter by filename (for list/restore)'),
|
||||||
|
limit: z.number().default(30).describe('Max items (for list)'),
|
||||||
|
},
|
||||||
|
async ({ action, filter, limit }) => {
|
||||||
|
switch (action) {
|
||||||
|
case 'size': {
|
||||||
|
const ps = `
|
||||||
|
$shell = New-Object -ComObject Shell.Application
|
||||||
|
$bin = $shell.Namespace(10)
|
||||||
|
$items = $bin.Items()
|
||||||
|
$count = $items.Count
|
||||||
|
$totalSize = 0
|
||||||
|
for ($i = 0; $i -lt $count; $i++) {
|
||||||
|
$totalSize += $bin.GetDetailsOf($items.Item($i), 2) -replace '[^0-9]','' -as [long]
|
||||||
|
}
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Count = $count
|
||||||
|
SizeMB = [math]::Round($totalSize / 1MB, 1)
|
||||||
|
} | ConvertTo-Json -Compress`;
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 15000 });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
|
||||||
|
}
|
||||||
|
const info = JSON.parse(result.stdout);
|
||||||
|
return { content: [{ type: 'text', text: `Recycle Bin: ${info.Count} items, ${info.SizeMB} MB` }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list': {
|
||||||
|
const filterClause = filter
|
||||||
|
? `| Where-Object { $_.Name -like '*${filter.replace(/'/g, "''")}*' }`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const ps = `
|
||||||
|
$shell = New-Object -ComObject Shell.Application
|
||||||
|
$bin = $shell.Namespace(10)
|
||||||
|
$items = @()
|
||||||
|
foreach ($item in $bin.Items()) {
|
||||||
|
$items += [PSCustomObject]@{
|
||||||
|
Name = $item.Name
|
||||||
|
OriginalPath = $bin.GetDetailsOf($item, 1)
|
||||||
|
Size = $bin.GetDetailsOf($item, 2)
|
||||||
|
DeletedDate = $bin.GetDetailsOf($item, 3)
|
||||||
|
Type = $bin.GetDetailsOf($item, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$items ${filterClause} | Select-Object -First ${limit} | ConvertTo-Json -Depth 3 -Compress`;
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 20000 });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
|
||||||
|
}
|
||||||
|
if (!result.stdout) {
|
||||||
|
return { content: [{ type: 'text', text: 'Recycle Bin is empty.' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)];
|
||||||
|
const lines = items.map((i: { Name: string; OriginalPath: string; Size: string; DeletedDate: string }) =>
|
||||||
|
`${(i.Name || '').padEnd(35).slice(0, 35)} ${(i.Size || '').padStart(10)} ${(i.DeletedDate || '').padEnd(20)} ${(i.OriginalPath || '').slice(0, 40)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = `${'Name'.padEnd(35)} ${'Size'.padStart(10)} ${'Deleted'.padEnd(20)} Original Path`;
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `${header}\n${'─'.repeat(110)}\n${lines.join('\n')}\n\n${items.length} items` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'empty': {
|
||||||
|
const ps = `
|
||||||
|
$shell = New-Object -ComObject Shell.Application
|
||||||
|
$count = $shell.Namespace(10).Items().Count
|
||||||
|
if ($count -eq 0) { "Recycle Bin is already empty." }
|
||||||
|
else {
|
||||||
|
Clear-RecycleBin -Force -ErrorAction Stop
|
||||||
|
"Emptied Recycle Bin ($count items removed)"
|
||||||
|
}`;
|
||||||
|
const result = await runPowerShell(ps, { timeout: 15000 });
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: result.stdout || result.stderr }],
|
||||||
|
isError: result.exitCode !== 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'restore': {
|
||||||
|
if (!filter) {
|
||||||
|
return { content: [{ type: 'text', text: 'Restore requires a filter to identify which item(s) to restore.' }], isError: true };
|
||||||
|
}
|
||||||
|
const ps = `
|
||||||
|
$shell = New-Object -ComObject Shell.Application
|
||||||
|
$bin = $shell.Namespace(10)
|
||||||
|
$restored = 0
|
||||||
|
foreach ($item in $bin.Items()) {
|
||||||
|
if ($item.Name -like '*${filter.replace(/'/g, "''")}*') {
|
||||||
|
$origPath = $bin.GetDetailsOf($item, 1)
|
||||||
|
$item.InvokeVerb('undelete')
|
||||||
|
$restored++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($restored -gt 0) { "Restored $restored item(s)" } else { "No matching items found" }`;
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 15000 });
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: result.stdout || result.stderr }],
|
||||||
|
isError: result.exitCode !== 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Tools: windows_registry_read (#29), windows_registry_write (#30)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { runPowerShell } from '../shell.js';
|
||||||
|
|
||||||
|
function expandHive(path: string): string {
|
||||||
|
return path
|
||||||
|
.replace(/^HKLM[:\\\/]/i, 'HKLM:\\')
|
||||||
|
.replace(/^HKCU[:\\\/]/i, 'HKCU:\\')
|
||||||
|
.replace(/^HKCR[:\\\/]/i, 'HKCR:\\')
|
||||||
|
.replace(/^HKU[:\\\/]/i, 'HKU:\\')
|
||||||
|
.replace(/^HKCC[:\\\/]/i, 'HKCC:\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerRegistryTools(server: McpServer): void {
|
||||||
|
server.tool(
|
||||||
|
'windows_registry_read',
|
||||||
|
'Read Windows Registry keys, subkeys, and values. Supports HKLM, HKCU, HKCR abbreviations.',
|
||||||
|
{
|
||||||
|
path: z.string().describe('Registry path (e.g. "HKCU:\\Software\\Microsoft")'),
|
||||||
|
value: z.string().optional().describe('Specific value name to read (omit to list all values)'),
|
||||||
|
subkeys: z.boolean().default(false).describe('List subkeys instead of values'),
|
||||||
|
},
|
||||||
|
async ({ path, value, subkeys }) => {
|
||||||
|
const regPath = expandHive(path);
|
||||||
|
|
||||||
|
if (subkeys) {
|
||||||
|
const ps = `
|
||||||
|
Get-ChildItem -Path '${regPath.replace(/'/g, "''")}' -ErrorAction Stop | ForEach-Object {
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = $_.PSChildName
|
||||||
|
SubKeyCount = $_.SubKeyCount
|
||||||
|
ValueCount = $_.ValueCount
|
||||||
|
}
|
||||||
|
} | ConvertTo-Json -Depth 3 -Compress`;
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 10000 });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
|
||||||
|
}
|
||||||
|
if (!result.stdout) {
|
||||||
|
return { content: [{ type: 'text', text: 'No subkeys found.' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)];
|
||||||
|
const lines = keys.map((k: { Name: string; SubKeyCount: number; ValueCount: number }) =>
|
||||||
|
` ${k.Name.padEnd(40)} ${k.SubKeyCount} subkeys, ${k.ValueCount} values`,
|
||||||
|
);
|
||||||
|
return { content: [{ type: 'text', text: `${regPath}\n${'─'.repeat(70)}\n${lines.join('\n')}` }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const ps = `
|
||||||
|
$v = Get-ItemProperty -Path '${regPath.replace(/'/g, "''")}' -Name '${value.replace(/'/g, "''")}' -ErrorAction Stop
|
||||||
|
$raw = $v.'${value.replace(/'/g, "''")}'
|
||||||
|
$kind = (Get-Item -Path '${regPath.replace(/'/g, "''")}' -ErrorAction Stop).GetValueKind('${value.replace(/'/g, "''")}')
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = '${value.replace(/'/g, "''")}'
|
||||||
|
Type = $kind.ToString()
|
||||||
|
Value = $raw
|
||||||
|
} | ConvertTo-Json -Depth 3 -Compress`;
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 10000 });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
|
||||||
|
}
|
||||||
|
const v = JSON.parse(result.stdout);
|
||||||
|
return { content: [{ type: 'text', text: `${regPath}\\${v.Name}\nType: ${v.Type}\nValue: ${JSON.stringify(v.Value)}` }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all values
|
||||||
|
const ps = `
|
||||||
|
$key = Get-Item -Path '${regPath.replace(/'/g, "''")}' -ErrorAction Stop
|
||||||
|
$key.GetValueNames() | ForEach-Object {
|
||||||
|
$name = $_
|
||||||
|
$val = $key.GetValue($name)
|
||||||
|
$kind = $key.GetValueKind($name)
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = if ($name) { $name } else { '(Default)' }
|
||||||
|
Type = $kind.ToString()
|
||||||
|
Value = $val
|
||||||
|
}
|
||||||
|
} | ConvertTo-Json -Depth 3 -Compress`;
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 10000 });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
|
||||||
|
}
|
||||||
|
if (!result.stdout) {
|
||||||
|
return { content: [{ type: 'text', text: 'No values found.' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)];
|
||||||
|
const lines = values.map((v: { Name: string; Type: string; Value: unknown }) => {
|
||||||
|
const valStr = typeof v.Value === 'string' ? v.Value : JSON.stringify(v.Value);
|
||||||
|
return ` ${v.Name.padEnd(30)} ${v.Type.padEnd(12)} ${valStr.slice(0, 60)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = ` ${'Name'.padEnd(30)} ${'Type'.padEnd(12)} Value`;
|
||||||
|
return { content: [{ type: 'text', text: `${regPath}\n${header}\n${'─'.repeat(80)}\n${lines.join('\n')}` }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'windows_registry_write',
|
||||||
|
'Write Windows Registry values. Restricted to HKCU by default. Use hklm_override for HKLM writes.',
|
||||||
|
{
|
||||||
|
path: z.string().describe('Registry path'),
|
||||||
|
name: z.string().describe('Value name'),
|
||||||
|
value: z.string().describe('Value data'),
|
||||||
|
type: z.enum(['String', 'DWord', 'QWord', 'Binary', 'ExpandString', 'MultiString']).default('String').describe('Value type'),
|
||||||
|
hklm_override: z.boolean().default(false).describe('Allow writing to HKLM (requires elevation)'),
|
||||||
|
action: z.enum(['set', 'delete', 'create_key', 'delete_key']).default('set').describe('Action'),
|
||||||
|
},
|
||||||
|
async ({ path, name, value, type, hklm_override, action }) => {
|
||||||
|
const regPath = expandHive(path);
|
||||||
|
|
||||||
|
// Safety check
|
||||||
|
if (regPath.startsWith('HKLM:') && !hklm_override) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'HKLM writes are restricted. Set hklm_override=true and ensure elevation.' }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let ps: string;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'set': {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
String: 'String', DWord: 'DWord', QWord: 'QWord',
|
||||||
|
Binary: 'Binary', ExpandString: 'ExpandString', MultiString: 'MultiString',
|
||||||
|
};
|
||||||
|
ps = `
|
||||||
|
if (-not (Test-Path '${regPath.replace(/'/g, "''")}')) {
|
||||||
|
New-Item -Path '${regPath.replace(/'/g, "''")}' -Force | Out-Null
|
||||||
|
}
|
||||||
|
Set-ItemProperty -Path '${regPath.replace(/'/g, "''")}' -Name '${name.replace(/'/g, "''")}' -Value '${value.replace(/'/g, "''")}' -Type ${typeMap[type]} -ErrorAction Stop
|
||||||
|
"Set ${regPath}\\${name} = ${value} (${type})"`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'delete':
|
||||||
|
ps = `Remove-ItemProperty -Path '${regPath.replace(/'/g, "''")}' -Name '${name.replace(/'/g, "''")}' -ErrorAction Stop; "Deleted ${regPath}\\${name}"`;
|
||||||
|
break;
|
||||||
|
case 'create_key':
|
||||||
|
ps = `New-Item -Path '${regPath.replace(/'/g, "''")}' -Force -ErrorAction Stop | Out-Null; "Created key ${regPath}"`;
|
||||||
|
break;
|
||||||
|
case 'delete_key':
|
||||||
|
ps = `Remove-Item -Path '${regPath.replace(/'/g, "''")}' -Recurse -Force -ErrorAction Stop; "Deleted key ${regPath}"`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 10000 });
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: result.stdout || result.stderr }],
|
||||||
|
isError: result.exitCode !== 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Tools: windows_task_scheduler_list (#27), windows_task_scheduler_manage (#28)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { runPowerShell } from '../shell.js';
|
||||||
|
|
||||||
|
export function registerSchedulerTools(server: McpServer): void {
|
||||||
|
server.tool(
|
||||||
|
'windows_task_scheduler_list',
|
||||||
|
'List Windows Task Scheduler tasks with status, last/next run, and trigger type.',
|
||||||
|
{
|
||||||
|
folder: z.string().default('\\').describe('Task folder path (e.g. "\\" for root, "\\Microsoft\\")'),
|
||||||
|
filter: z.string().optional().describe('Filter by task name (substring)'),
|
||||||
|
},
|
||||||
|
async ({ folder, filter }) => {
|
||||||
|
const filterClause = filter
|
||||||
|
? `| Where-Object { $_.TaskName -like '*${filter.replace(/'/g, "''")}*' }`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const ps = `
|
||||||
|
Get-ScheduledTask -TaskPath '${folder.replace(/'/g, "''")}*' -ErrorAction SilentlyContinue ${filterClause} | ForEach-Object {
|
||||||
|
$info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = $_.TaskName
|
||||||
|
Path = $_.TaskPath
|
||||||
|
State = $_.State.ToString()
|
||||||
|
LastRun = if ($info.LastRunTime -and $info.LastRunTime.Year -gt 1999) { $info.LastRunTime.ToString('yyyy-MM-dd HH:mm') } else { 'Never' }
|
||||||
|
NextRun = if ($info.NextRunTime -and $info.NextRunTime.Year -gt 1999) { $info.NextRunTime.ToString('yyyy-MM-dd HH:mm') } else { 'N/A' }
|
||||||
|
LastResult = if ($info) { '0x{0:X}' -f $info.LastTaskResult } else { 'N/A' }
|
||||||
|
Triggers = ($_.Triggers | ForEach-Object { $_.CimClass.CimClassName -replace 'MSFT_Task',''-replace 'Trigger','' }) -join ', '
|
||||||
|
}
|
||||||
|
} | ConvertTo-Json -Depth 3 -Compress`;
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 30000 });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.stdout) {
|
||||||
|
return { content: [{ type: 'text', text: 'No tasks found.' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)];
|
||||||
|
const lines = tasks.map((t: { Name: string; State: string; LastRun: string; NextRun: string; Triggers: string }) => {
|
||||||
|
const state = t.State === 'Ready' ? '[RDY]' : t.State === 'Running' ? '[RUN]' : t.State === 'Disabled' ? '[OFF]' : `[${t.State.slice(0, 3).toUpperCase()}]`;
|
||||||
|
return `${state} ${t.Name.padEnd(40).slice(0, 40)} ${t.LastRun.padEnd(16)} ${t.NextRun.padEnd(16)} ${t.Triggers}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = `State ${'Name'.padEnd(40)} ${'Last Run'.padEnd(16)} ${'Next Run'.padEnd(16)} Triggers`;
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `${header}\n${'─'.repeat(110)}\n${lines.join('\n')}\n\n${tasks.length} tasks` }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'windows_task_scheduler_manage',
|
||||||
|
'Create, delete, enable, disable, or run a scheduled task.',
|
||||||
|
{
|
||||||
|
action: z.enum(['create', 'delete', 'enable', 'disable', 'run']).describe('Action to perform'),
|
||||||
|
name: z.string().describe('Task name'),
|
||||||
|
command: z.string().optional().describe('Command to execute (for create)'),
|
||||||
|
arguments: z.string().optional().describe('Command arguments (for create)'),
|
||||||
|
trigger: z.enum(['once', 'daily', 'weekly', 'hourly', 'logon', 'startup']).optional().describe('Trigger type (for create)'),
|
||||||
|
time: z.string().optional().describe('Time for trigger as HH:mm (for create with once/daily/weekly)'),
|
||||||
|
interval: z.number().optional().describe('Repetition interval in minutes (for create with hourly)'),
|
||||||
|
},
|
||||||
|
async ({ action, name, command, arguments: args, trigger, time, interval }) => {
|
||||||
|
let ps: string;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'run':
|
||||||
|
ps = `Start-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -ErrorAction Stop; "Task '${name}' started"`;
|
||||||
|
break;
|
||||||
|
case 'enable':
|
||||||
|
ps = `Enable-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -ErrorAction Stop | Select-Object TaskName,State | ConvertTo-Json -Compress`;
|
||||||
|
break;
|
||||||
|
case 'disable':
|
||||||
|
ps = `Disable-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -ErrorAction Stop | Select-Object TaskName,State | ConvertTo-Json -Compress`;
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
ps = `Unregister-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -Confirm:$false -ErrorAction Stop; "Task '${name}' deleted"`;
|
||||||
|
break;
|
||||||
|
case 'create': {
|
||||||
|
if (!command) {
|
||||||
|
return { content: [{ type: 'text', text: 'Create requires command.' }], isError: true };
|
||||||
|
}
|
||||||
|
if (!trigger) {
|
||||||
|
return { content: [{ type: 'text', text: 'Create requires trigger type.' }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionPart = args
|
||||||
|
? `$action = New-ScheduledTaskAction -Execute '${command.replace(/'/g, "''")}' -Argument '${args.replace(/'/g, "''")}'`
|
||||||
|
: `$action = New-ScheduledTaskAction -Execute '${command.replace(/'/g, "''")}'`;
|
||||||
|
|
||||||
|
let triggerPart: string;
|
||||||
|
switch (trigger) {
|
||||||
|
case 'once':
|
||||||
|
triggerPart = `$trigger = New-ScheduledTaskTrigger -Once -At '${time || '00:00'}'`;
|
||||||
|
break;
|
||||||
|
case 'daily':
|
||||||
|
triggerPart = `$trigger = New-ScheduledTaskTrigger -Daily -At '${time || '00:00'}'`;
|
||||||
|
break;
|
||||||
|
case 'weekly':
|
||||||
|
triggerPart = `$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At '${time || '00:00'}'`;
|
||||||
|
break;
|
||||||
|
case 'hourly':
|
||||||
|
triggerPart = `$trigger = New-ScheduledTaskTrigger -Once -At '00:00' -RepetitionInterval (New-TimeSpan -Minutes ${interval || 60}) -RepetitionDuration (New-TimeSpan -Days 9999)`;
|
||||||
|
break;
|
||||||
|
case 'logon':
|
||||||
|
triggerPart = `$trigger = New-ScheduledTaskTrigger -AtLogOn`;
|
||||||
|
break;
|
||||||
|
case 'startup':
|
||||||
|
triggerPart = `$trigger = New-ScheduledTaskTrigger -AtStartup`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ps = `
|
||||||
|
${actionPart}
|
||||||
|
${triggerPart}
|
||||||
|
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||||
|
Register-ScheduledTask -TaskName '${name.replace(/'/g, "''")}' -Action $action -Trigger $trigger -Settings $settings -Force -ErrorAction Stop |
|
||||||
|
Select-Object TaskName,State | ConvertTo-Json -Compress`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 15000 });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: [{ type: 'text', text: result.stdout }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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}` }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* Tools: windows_startup_list (#33), windows_startup_manage (#34)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { runPowerShell } from '../shell.js';
|
||||||
|
|
||||||
|
export function registerStartupTools(server: McpServer): void {
|
||||||
|
server.tool(
|
||||||
|
'windows_startup_list',
|
||||||
|
'List all startup items from registry (Run/RunOnce), startup folder, and scheduled logon tasks.',
|
||||||
|
{},
|
||||||
|
async () => {
|
||||||
|
const ps = `
|
||||||
|
$items = [System.Collections.Generic.List[PSObject]]::new()
|
||||||
|
|
||||||
|
# Registry: HKCU Run
|
||||||
|
$regPaths = @(
|
||||||
|
@{ Path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; Scope = 'User'; Source = 'Registry Run' }
|
||||||
|
@{ Path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce'; Scope = 'User'; Source = 'Registry RunOnce' }
|
||||||
|
@{ Path = 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; Scope = 'System'; Source = 'Registry Run' }
|
||||||
|
@{ Path = 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce'; Scope = 'System'; Source = 'Registry RunOnce' }
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($rp in $regPaths) {
|
||||||
|
if (Test-Path $rp.Path) {
|
||||||
|
$props = Get-ItemProperty -Path $rp.Path -ErrorAction SilentlyContinue
|
||||||
|
if ($props) {
|
||||||
|
$props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
|
||||||
|
$items.Add([PSCustomObject]@{
|
||||||
|
Name = $_.Name
|
||||||
|
Command = $_.Value
|
||||||
|
Source = $rp.Source
|
||||||
|
Scope = $rp.Scope
|
||||||
|
Enabled = $true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Startup folder
|
||||||
|
$userStartup = [Environment]::GetFolderPath('Startup')
|
||||||
|
$commonStartup = [Environment]::GetFolderPath('CommonStartup')
|
||||||
|
foreach ($folder in @(@{Path=$userStartup;Scope='User'}, @{Path=$commonStartup;Scope='System'})) {
|
||||||
|
if (Test-Path $folder.Path) {
|
||||||
|
Get-ChildItem -Path $folder.Path -File -ErrorAction SilentlyContinue | ForEach-Object {
|
||||||
|
$items.Add([PSCustomObject]@{
|
||||||
|
Name = $_.BaseName
|
||||||
|
Command = $_.FullName
|
||||||
|
Source = 'Startup Folder'
|
||||||
|
Scope = $folder.Scope
|
||||||
|
Enabled = $true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Scheduled tasks at logon
|
||||||
|
Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object {
|
||||||
|
$_.Triggers | Where-Object { $_ -is [CimInstance] -and $_.CimClass.CimClassName -eq 'MSFT_TaskLogonTrigger' }
|
||||||
|
} | ForEach-Object {
|
||||||
|
$items.Add([PSCustomObject]@{
|
||||||
|
Name = $_.TaskName
|
||||||
|
Command = ($_.Actions | ForEach-Object { $_.Execute }) -join ' '
|
||||||
|
Source = 'Task Scheduler (Logon)'
|
||||||
|
Scope = 'System'
|
||||||
|
Enabled = $_.State -eq 'Ready'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$items | ConvertTo-Json -Depth 3 -Compress`;
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 20000 });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.stdout) {
|
||||||
|
return { content: [{ type: 'text', text: 'No startup items found.' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.isArray(JSON.parse(result.stdout)) ? JSON.parse(result.stdout) : [JSON.parse(result.stdout)];
|
||||||
|
const lines = items.map((i: { Name: string; Command: string; Source: string; Scope: string; Enabled: boolean }) => {
|
||||||
|
const status = i.Enabled ? '[ON] ' : '[OFF]';
|
||||||
|
return `${status} ${i.Scope.padEnd(6)} ${i.Source.padEnd(24)} ${i.Name.padEnd(30).slice(0, 30)} ${(i.Command || '').slice(0, 50)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = `State ${'Scope'.padEnd(6)} ${'Source'.padEnd(24)} ${'Name'.padEnd(30)} Command`;
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `${header}\n${'─'.repeat(110)}\n${lines.join('\n')}\n\n${items.length} startup items` }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'windows_startup_manage',
|
||||||
|
'Add, remove, enable, or disable a startup item.',
|
||||||
|
{
|
||||||
|
action: z.enum(['add', 'remove', 'enable', 'disable']).describe('Action'),
|
||||||
|
name: z.string().describe('Startup item name'),
|
||||||
|
command: z.string().optional().describe('Command to run at startup (for add)'),
|
||||||
|
location: z.enum(['registry', 'startup_folder']).default('registry').describe('Where to add (for add)'),
|
||||||
|
},
|
||||||
|
async ({ action, name, command, location }) => {
|
||||||
|
let ps: string;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'add':
|
||||||
|
if (!command) {
|
||||||
|
return { content: [{ type: 'text', text: 'Add requires a command.' }], isError: true };
|
||||||
|
}
|
||||||
|
if (location === 'startup_folder') {
|
||||||
|
ps = `
|
||||||
|
$startupPath = [Environment]::GetFolderPath('Startup')
|
||||||
|
$shortcutPath = Join-Path $startupPath '${name.replace(/'/g, "''")}.lnk'
|
||||||
|
$ws = New-Object -ComObject WScript.Shell
|
||||||
|
$sc = $ws.CreateShortcut($shortcutPath)
|
||||||
|
$sc.TargetPath = '${command.replace(/'/g, "''")}'
|
||||||
|
$sc.Save()
|
||||||
|
"Added startup shortcut: $shortcutPath"`;
|
||||||
|
} else {
|
||||||
|
ps = `
|
||||||
|
Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name '${name.replace(/'/g, "''")}' -Value '${command.replace(/'/g, "''")}' -ErrorAction Stop
|
||||||
|
"Added to HKCU Run: ${name}"`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'remove':
|
||||||
|
ps = `
|
||||||
|
$removed = $false
|
||||||
|
# Try registry
|
||||||
|
$regPath = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
|
||||||
|
if ((Get-ItemProperty -Path $regPath -Name '${name.replace(/'/g, "''")}' -ErrorAction SilentlyContinue)) {
|
||||||
|
Remove-ItemProperty -Path $regPath -Name '${name.replace(/'/g, "''")}' -ErrorAction Stop
|
||||||
|
$removed = $true
|
||||||
|
}
|
||||||
|
# Try startup folder
|
||||||
|
$startupPath = [Environment]::GetFolderPath('Startup')
|
||||||
|
$lnk = Join-Path $startupPath '${name.replace(/'/g, "''")}.lnk'
|
||||||
|
if (Test-Path $lnk) { Remove-Item $lnk -Force; $removed = $true }
|
||||||
|
if ($removed) { "Removed: ${name}" } else { "Not found: ${name}" }`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'disable':
|
||||||
|
ps = `
|
||||||
|
$regPath = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
|
||||||
|
$val = (Get-ItemProperty -Path $regPath -Name '${name.replace(/'/g, "''")}' -ErrorAction SilentlyContinue).'${name.replace(/'/g, "''")}'
|
||||||
|
if ($val) {
|
||||||
|
Remove-ItemProperty -Path $regPath -Name '${name.replace(/'/g, "''")}' -ErrorAction Stop
|
||||||
|
$disabledPath = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run_Disabled'
|
||||||
|
if (-not (Test-Path $disabledPath)) { New-Item -Path $disabledPath -Force | Out-Null }
|
||||||
|
Set-ItemProperty -Path $disabledPath -Name '${name.replace(/'/g, "''")}' -Value $val
|
||||||
|
"Disabled: ${name}"
|
||||||
|
} else { "Not found in registry Run: ${name}" }`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'enable':
|
||||||
|
ps = `
|
||||||
|
$disabledPath = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run_Disabled'
|
||||||
|
$val = (Get-ItemProperty -Path $disabledPath -Name '${name.replace(/'/g, "''")}' -ErrorAction SilentlyContinue).'${name.replace(/'/g, "''")}'
|
||||||
|
if ($val) {
|
||||||
|
Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name '${name.replace(/'/g, "''")}' -Value $val
|
||||||
|
Remove-ItemProperty -Path $disabledPath -Name '${name.replace(/'/g, "''")}' -ErrorAction SilentlyContinue
|
||||||
|
"Enabled: ${name}"
|
||||||
|
} else { "Not found in disabled items: ${name}" }`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runPowerShell(ps, { timeout: 10000 });
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: result.stdout || result.stderr }],
|
||||||
|
isError: result.exitCode !== 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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 }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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}.`,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
*
|
|
||||||
* 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<string, ApiConnection>;
|
|
||||||
defaultConnection: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalized API response returned by the HTTP client.
|
|
||||||
*/
|
|
||||||
export interface ApiResponse {
|
|
||||||
status: number;
|
|
||||||
data: unknown;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user