Super Windows CLI MCP Server

by delorenj
Verified
  • src
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js"; import { isCommandBlocked, isArgumentBlocked, parseCommand, extractCommandName, validateShellOperators } from './utils/validation.js'; import { spawn } from 'child_process'; import { z } from 'zod'; import path from 'path'; import { loadConfig, createDefaultConfig } from './utils/config.js'; import type { ServerConfig, CommandHistoryEntry, SSHConnectionConfig } from './types/config.js'; import { SSHConnectionPool } from './utils/ssh.js'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); const packageJson = require('../package.json'); // Parse command line arguments using yargs import yargs from 'yargs/yargs'; import { hideBin } from 'yargs/helpers'; const parseArgs = async () => { return yargs(hideBin(process.argv)) .option('config', { alias: 'c', type: 'string', description: 'Path to config file' }) .option('init-config', { type: 'string', description: 'Create a default config file at the specified path' }) .help() .parse(); }; class CLIServer { private server: Server; private allowedPaths: Set<string>; private blockedCommands: Set<string>; private commandHistory: CommandHistoryEntry[]; private config: ServerConfig; private sshPool: SSHConnectionPool; constructor(config: ServerConfig) { this.config = config; this.server = new Server({ name: "windows-cli-server", version: packageJson.version, }, { capabilities: { tools: {} } }); // Initialize from config this.allowedPaths = new Set(config.security.allowedPaths); this.blockedCommands = new Set(config.security.blockedCommands); this.commandHistory = []; this.sshPool = new SSHConnectionPool(); this.setupHandlers(); } private validateCommand(shell: keyof ServerConfig['shells'], command: string): void { // Check for command chaining/injection attempts if enabled if (this.config.security.enableInjectionProtection) { // Get shell-specific config const shellConfig = this.config.shells[shell]; // Use shell-specific operator validation validateShellOperators(command, shellConfig); } const { command: executable, args } = parseCommand(command); // Check for blocked commands if (isCommandBlocked(executable, Array.from(this.blockedCommands))) { throw new McpError( ErrorCode.InvalidRequest, `Command is blocked: "${extractCommandName(executable)}"` ); } // Check for blocked arguments if (isArgumentBlocked(args, this.config.security.blockedArguments)) { throw new McpError( ErrorCode.InvalidRequest, 'One or more arguments are blocked. Check configuration for blocked patterns.' ); } // Validate command length if (command.length > this.config.security.maxCommandLength) { throw new McpError( ErrorCode.InvalidRequest, `Command exceeds maximum length of ${this.config.security.maxCommandLength}` ); } } /** * Escapes special characters in a string for use in a regular expression * @param text The string to escape * @returns The escaped string */ private escapeRegex(text: string): string { return text.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); } private setupHandlers(): void { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "execute_command", description: `Execute a command in the specified shell (powershell, cmd, or gitbash) Example usage (PowerShell): \`\`\`json { "shell": "powershell", "command": "Get-Process | Select-Object -First 5", "workingDir": "C:\\Users\\username" } \`\`\` Example usage (CMD): \`\`\`json { "shell": "cmd", "command": "dir /b", "workingDir": "C:\\Projects" } \`\`\` Example usage (Git Bash): \`\`\`json { "shell": "gitbash", "command": "ls -la", "workingDir": "/c/Users/username" } \`\`\``, inputSchema: { type: "object", properties: { shell: { type: "string", enum: Object.keys(this.config.shells).filter(shell => this.config.shells[shell as keyof typeof this.config.shells].enabled ), description: "Shell to use for command execution" }, command: { type: "string", description: "Command to execute" }, workingDir: { type: "string", description: "Working directory for command execution (optional)" } }, required: ["shell", "command"] } }, { name: "get_command_history", description: `Get the history of executed commands Example usage: \`\`\`json { "limit": 5 } \`\`\` Example response: \`\`\`json [ { "command": "Get-Process", "output": "...", "timestamp": "2024-03-20T10:30:00Z", "exitCode": 0 } ] \`\`\``, inputSchema: { type: "object", properties: { limit: { type: "number", description: `Maximum number of history entries to return (default: 10, max: ${this.config.security.maxHistorySize})` } } } }, { name: "ssh_execute", description: `Execute a command on a remote host via SSH Example usage: \`\`\`json { "connectionId": "raspberry-pi", "command": "uname -a" } \`\`\` Configuration required in config.json: \`\`\`json { "ssh": { "enabled": true, "connections": { "raspberry-pi": { "host": "raspberrypi.local", "port": 22, "username": "pi", "password": "raspberry" } } } } \`\`\``, inputSchema: { type: "object", properties: { connectionId: { type: "string", description: "ID of the SSH connection to use", enum: Object.keys(this.config.ssh.connections) }, command: { type: "string", description: "Command to execute" } }, required: ["connectionId", "command"] } }, { name: "ssh_disconnect", description: `Disconnect from an SSH server Example usage: \`\`\`json { "connectionId": "raspberry-pi" } \`\`\` Use this to cleanly close SSH connections when they're no longer needed.`, inputSchema: { type: "object", properties: { connectionId: { type: "string", description: "ID of the SSH connection to disconnect", enum: Object.keys(this.config.ssh.connections) } }, required: ["connectionId"] } } ] })); // Handle tool execution this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { switch (request.params.name) { case "execute_command": { const args = z.object({ shell: z.enum(Object.keys(this.config.shells).filter(shell => this.config.shells[shell as keyof typeof this.config.shells].enabled ) as [string, ...string[]]), command: z.string(), workingDir: z.string().optional() }).parse(request.params.arguments); // Validate command this.validateCommand(args.shell as keyof ServerConfig['shells'], args.command); // Validate working directory if provided let workingDir = args.workingDir ? path.resolve(args.workingDir) : process.cwd(); const shellKey = args.shell as keyof typeof this.config.shells; const shellConfig = this.config.shells[shellKey]; if (this.config.security.restrictWorkingDirectory) { const isAllowedPath = Array.from(this.allowedPaths).some( allowedPath => workingDir.startsWith(allowedPath) ); if (!isAllowedPath) { throw new McpError( ErrorCode.InvalidRequest, `Working directory (${workingDir}) outside allowed paths. Consult the server admin for configuration changes (config.json - restrictWorkingDirectory, allowedPaths).` ); } } // Execute command return new Promise((resolve, reject) => { let shellProcess: ReturnType<typeof spawn>; try { shellProcess = spawn( shellConfig.command, [...shellConfig.args, args.command], { cwd: workingDir, stdio: ['pipe', 'pipe', 'pipe'] } ); } catch (err) { throw new McpError( ErrorCode.InternalError, `Failed to start shell process: ${err instanceof Error ? err.message : String(err)}. Consult the server admin for configuration changes (config.json - shells).` ); } if (!shellProcess.stdout || !shellProcess.stderr) { throw new McpError( ErrorCode.InternalError, 'Failed to initialize shell process streams' ); } let output = ''; let error = ''; shellProcess.stdout.on('data', (data) => { output += data.toString(); }); shellProcess.stderr.on('data', (data) => { error += data.toString(); }); shellProcess.on('close', (code) => { // Prepare detailed result message let resultMessage = ''; if (code === 0) { resultMessage = output || 'Command completed successfully (no output)'; } else { resultMessage = `Command failed with exit code ${code}\n`; if (error) { resultMessage += `Error output:\n${error}\n`; } if (output) { resultMessage += `Standard output:\n${output}`; } if (!error && !output) { resultMessage += 'No error message or output was provided'; } } // Store in history if enabled if (this.config.security.logCommands) { this.commandHistory.push({ command: args.command, output: resultMessage, timestamp: new Date().toISOString(), exitCode: code ?? -1 }); // Trim history if needed if (this.commandHistory.length > this.config.security.maxHistorySize) { this.commandHistory = this.commandHistory.slice(-this.config.security.maxHistorySize); } } resolve({ content: [{ type: "text", text: resultMessage }], isError: code !== 0, metadata: { exitCode: code ?? -1, shell: args.shell, workingDirectory: workingDir } }); }); // Handle process errors (e.g., shell crashes) shellProcess.on('error', (err) => { const errorMessage = `Shell process error: ${err.message}`; if (this.config.security.logCommands) { this.commandHistory.push({ command: args.command, output: errorMessage, timestamp: new Date().toISOString(), exitCode: -1 }); } reject(new McpError( ErrorCode.InternalError, errorMessage )); }); // Set configurable timeout to prevent hanging const timeout = setTimeout(() => { shellProcess.kill(); const timeoutMessage = `Command execution timed out after ${this.config.security.commandTimeout} seconds. Consult the server admin for configuration changes (config.json - commandTimeout).`; if (this.config.security.logCommands) { this.commandHistory.push({ command: args.command, output: timeoutMessage, timestamp: new Date().toISOString(), exitCode: -1 }); } reject(new McpError( ErrorCode.InternalError, timeoutMessage )); }, this.config.security.commandTimeout * 1000); shellProcess.on('close', () => clearTimeout(timeout)); }); } case "get_command_history": { if (!this.config.security.logCommands) { return { content: [{ type: "text", text: "Command history is disabled in configuration. Consult the server admin for configuration changes (config.json - logCommands)." }] }; } const args = z.object({ limit: z.number() .min(1) .max(this.config.security.maxHistorySize) .optional() .default(10) }).parse(request.params.arguments); const history = this.commandHistory .slice(-args.limit) .map(entry => ({ ...entry, output: entry.output.slice(0, 1000) // Limit output size })); return { content: [{ type: "text", text: JSON.stringify(history, null, 2) }] }; } case "ssh_execute": { if (!this.config.ssh.enabled) { throw new McpError( ErrorCode.InvalidRequest, "SSH support is disabled in configuration" ); } const args = z.object({ connectionId: z.string(), command: z.string() }).parse(request.params.arguments); const connectionConfig = this.config.ssh.connections[args.connectionId]; if (!connectionConfig) { throw new McpError( ErrorCode.InvalidRequest, `Unknown SSH connection ID: ${args.connectionId}` ); } try { // Validate command this.validateCommand('cmd', args.command); const connection = await this.sshPool.getConnection(args.connectionId, connectionConfig); const { output, exitCode } = await connection.executeCommand(args.command); // Store in history if enabled if (this.config.security.logCommands) { this.commandHistory.push({ command: args.command, output, timestamp: new Date().toISOString(), exitCode, connectionId: args.connectionId }); if (this.commandHistory.length > this.config.security.maxHistorySize) { this.commandHistory = this.commandHistory.slice(-this.config.security.maxHistorySize); } } return { content: [{ type: "text", text: output || 'Command completed successfully (no output)' }], isError: exitCode !== 0, metadata: { exitCode, connectionId: args.connectionId } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (this.config.security.logCommands) { this.commandHistory.push({ command: args.command, output: `SSH error: ${errorMessage}`, timestamp: new Date().toISOString(), exitCode: -1, connectionId: args.connectionId }); } throw new McpError( ErrorCode.InternalError, `SSH error: ${errorMessage}` ); } } case "ssh_disconnect": { if (!this.config.ssh.enabled) { throw new McpError( ErrorCode.InvalidRequest, "SSH support is disabled in configuration" ); } const args = z.object({ connectionId: z.string() }).parse(request.params.arguments); await this.sshPool.closeConnection(args.connectionId); return { content: [{ type: "text", text: `Disconnected from ${args.connectionId}` }] }; } default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } } catch (error) { if (error instanceof z.ZodError) { throw new McpError( ErrorCode.InvalidParams, `Invalid arguments: ${error.errors.map(e => e.message).join(', ')}` ); } throw error; } }); } private async cleanup(): Promise<void> { this.sshPool.closeAll(); } async run(): Promise<void> { const transport = new StdioServerTransport(); // Set up cleanup handler process.on('SIGINT', async () => { await this.cleanup(); process.exit(0); }); await this.server.connect(transport); console.error("Windows CLI MCP Server running on stdio"); } } // Start server const main = async () => { try { const args = await parseArgs(); // Handle --init-config flag if (args['init-config']) { try { createDefaultConfig(args['init-config'] as string); console.error(`Created default config at: ${args['init-config']}`); process.exit(0); } catch (error) { console.error('Failed to create config file:', error); process.exit(1); } } // Load configuration const config = loadConfig(args.config); const server = new CLIServer(config); await server.run(); } catch (error) { console.error("Fatal error:", error); process.exit(1); } }; main();