feat: implement v1.0 high-priority tools (14 tools)
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Replace template API scaffolding with Windows desktop system tools: - shell.ts: PowerShell/cmd/bash executor + persistent terminal sessions - tools/execute.ts: windows_execute (#1) - tools/process.ts: windows_process_list (#2) - tools/audio.ts: windows_audio_get, windows_audio_set (#6, #7) - tools/system.ts: windows_system_info (#18) - tools/terminal.ts: windows_terminal_start/send/read/list/kill (#35) - tools/filesystem.ts: windows_file_read/write/edit, windows_search (#36-39) Removes template API client/config/types (not needed for local OS MCP). Authored-by: Moko Consulting
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;
|
||||
}
|
||||
+16
-176
@@ -1,199 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
/* 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.Server
|
||||
* INGROUP: mcp_windows
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mcp_windows
|
||||
* PATH: /src/index.ts
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: MCP server entry point — registers all Windows Desktop API tools
|
||||
* mcp_windows — MCP server for Windows desktop system operations
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import { loadConfig, getConnection } from './config.js';
|
||||
import { ApiClient } from './client.js';
|
||||
import type { ApiConfig, ApiResponse } from './types.js';
|
||||
|
||||
let config: ApiConfig;
|
||||
|
||||
function clientFor(connection?: string): ApiClient {
|
||||
return new ApiClient(getConnection(config, connection));
|
||||
}
|
||||
|
||||
function formatResponse(res: ApiResponse): { content: Array<{ type: 'text'; text: string }> } {
|
||||
if (res.status >= 400) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: HTTP ${res.status}: ${JSON.stringify(res.data, null, 2)}` }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(res.data, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Shared parameter definitions ────────────────────────────────────────
|
||||
|
||||
const ConnectionParam = {
|
||||
connection: z.string().optional().describe('Named connection from config (uses default if omitted)'),
|
||||
};
|
||||
|
||||
const PaginationParams = {
|
||||
limit: z.number().optional().describe('Max results'),
|
||||
page: z.number().optional().describe('Page number (0-based)'),
|
||||
};
|
||||
|
||||
function paginationQuery(params: { limit?: number; page?: number }): Record<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 ──────────────────────────────────────────────────────────────
|
||||
import { registerExecuteTools } from './tools/execute.js';
|
||||
import { registerProcessTools } from './tools/process.js';
|
||||
import { registerAudioTools } from './tools/audio.js';
|
||||
import { registerSystemTools } from './tools/system.js';
|
||||
import { registerTerminalTools } from './tools/terminal.js';
|
||||
import { registerFilesystemTools } from './tools/filesystem.js';
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'mcp_windows',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// ADD YOUR TOOLS BELOW
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Follow this pattern for each tool:
|
||||
//
|
||||
// server.tool(
|
||||
// 'prefix_resource_action', // tool name (snake_case)
|
||||
// 'Human-readable description', // shown to the AI assistant
|
||||
// { // Zod schema for parameters
|
||||
// id: z.number().describe('Resource ID'),
|
||||
// ...ConnectionParam,
|
||||
// },
|
||||
// async ({ id, connection }) => {
|
||||
// const client = clientFor(connection);
|
||||
// return formatResponse(await client.get(`/resources/${id}`));
|
||||
// },
|
||||
// );
|
||||
//
|
||||
// Tips:
|
||||
// - Group tools by resource type with section comments
|
||||
// - Use consistent naming: prefix_resource_list, prefix_resource_get,
|
||||
// prefix_resource_create, prefix_resource_update, prefix_resource_delete
|
||||
// - Spread ...ConnectionParam into every tool's schema
|
||||
// - Spread ...PaginationParams into list tools
|
||||
// - Use paginationQuery() to build query params for list endpoints
|
||||
//
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ── Example: Resources ──────────────────────────────────────────────────
|
||||
|
||||
server.tool(
|
||||
'example_resources_list',
|
||||
'List resources (EXAMPLE — replace with your API resources)',
|
||||
{
|
||||
search: z.string().optional().describe('Search query'),
|
||||
...PaginationParams,
|
||||
...ConnectionParam,
|
||||
},
|
||||
async ({ search, limit, page, connection }) => {
|
||||
const client = clientFor(connection);
|
||||
const params: Record<string, string> = { ...paginationQuery({ limit, page }) };
|
||||
if (search) params['search'] = search;
|
||||
return formatResponse(await client.get('/resources', params));
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'example_resource_get',
|
||||
'Get a single resource by ID (EXAMPLE — replace with your API resources)',
|
||||
{
|
||||
id: z.number().describe('Resource ID'),
|
||||
...ConnectionParam,
|
||||
},
|
||||
async ({ id, connection }) => {
|
||||
const client = clientFor(connection);
|
||||
return formatResponse(await client.get(`/resources/${id}`));
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'example_resource_create',
|
||||
'Create a new resource (EXAMPLE — replace with your API resources)',
|
||||
{
|
||||
name: z.string().describe('Resource name'),
|
||||
description: z.string().optional().describe('Resource description'),
|
||||
...ConnectionParam,
|
||||
},
|
||||
async ({ name, description, connection }) => {
|
||||
const client = clientFor(connection);
|
||||
const body: Record<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 ────────────────────────────────────────────────────────
|
||||
// Register all tool groups
|
||||
registerExecuteTools(server);
|
||||
registerProcessTools(server);
|
||||
registerAudioTools(server);
|
||||
registerSystemTools(server);
|
||||
registerTerminalTools(server);
|
||||
registerFilesystemTools(server);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
config = await loadConfig();
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.stderr.write('mcp_windows: server started\n');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`Fatal: ${err}\n`);
|
||||
process.stderr.write(`mcp_windows: fatal error: ${err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
+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,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,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,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,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}.`,
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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