├── .gitignore ├── tsconfig.json ├── jest.config.js ├── smithery.yaml ├── Dockerfile ├── LICENSE ├── src ├── types │ └── config.ts ├── utils │ ├── sshManager.ts │ ├── ssh.ts │ ├── validation.ts │ └── config.ts └── index.ts ├── package.json ├── config.sample.json ├── tests └── validation.test.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .env 4 | .DS_Store 5 | *.log 6 | config.json 7 | .cursorrules 8 | .history 9 | CLAUDE.md 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "outDir": "./dist", 9 | "rootDir": "./src", 10 | "skipLibCheck": true, 11 | "resolveJsonModule": true 12 | }, 13 | "include": [ 14 | "src/**/*" 15 | ] 16 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | extensionsToTreatAsEsm: ['.ts'], 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | }, 9 | transform: { 10 | '^.+\\.tsx?$': [ 11 | 'ts-jest', 12 | { 13 | useESM: true, 14 | }, 15 | ], 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - configPath 10 | properties: 11 | configPath: 12 | type: string 13 | description: Path to the configuration file for the Windows CLI MCP server. 14 | commandFunction: 15 | # A function that produces the CLI command to start the MCP on stdio. 16 | |- 17 | (config) => ({ command: 'node', args: ['dist/index.js', '--config', config.configPath] }) 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | # Use an official Node.js runtime as a parent image 3 | FROM node:18-alpine AS build 4 | 5 | # Set the working directory in the container 6 | WORKDIR /app 7 | 8 | # Copy the package files and install the dependencies 9 | COPY package.json package-lock.json ./ 10 | RUN npm install --ignore-scripts 11 | 12 | # Copy the rest of the application 13 | COPY . . 14 | 15 | # Build the TypeScript code 16 | RUN npm run build 17 | 18 | # Use a lighter image for the runtime 19 | FROM node:18-alpine AS runtime 20 | 21 | WORKDIR /app 22 | 23 | # Copy the built application from the build stage 24 | COPY --from=build /app/dist ./dist 25 | COPY --from=build /app/package.json ./package.json 26 | COPY --from=build /app/package-lock.json ./package-lock.json 27 | 28 | # Install only production dependencies 29 | RUN npm ci --omit=dev 30 | 31 | # Command to run the application 32 | ENTRYPOINT ["node", "dist/index.js"] 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Simon Benedict 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/types/config.ts: -------------------------------------------------------------------------------- 1 | export interface SecurityConfig { 2 | maxCommandLength: number; 3 | blockedCommands: string[]; 4 | blockedArguments: string[]; 5 | allowedPaths: string[]; 6 | restrictWorkingDirectory: boolean; 7 | logCommands: boolean; 8 | maxHistorySize: number; 9 | commandTimeout: number; 10 | enableInjectionProtection: boolean; 11 | } 12 | 13 | export interface ShellConfig { 14 | enabled: boolean; 15 | command: string; 16 | args: string[]; 17 | validatePath?: (dir: string) => boolean; 18 | blockedOperators?: string[]; // Added for shell-specific operator restrictions 19 | } 20 | 21 | export interface SSHConnectionConfig { 22 | host: string; 23 | port: number; 24 | username: string; 25 | privateKeyPath?: string; 26 | password?: string; 27 | keepaliveInterval?: number; 28 | keepaliveCountMax?: number; 29 | readyTimeout?: number; 30 | } 31 | 32 | export interface SSHConfig { 33 | enabled: boolean; 34 | connections: Record; 35 | defaultTimeout: number; 36 | maxConcurrentSessions: number; 37 | keepaliveInterval: number; 38 | keepaliveCountMax: number; 39 | readyTimeout: number; 40 | } 41 | 42 | export interface ServerConfig { 43 | security: SecurityConfig; 44 | shells: { 45 | powershell: ShellConfig; 46 | cmd: ShellConfig; 47 | gitbash: ShellConfig; 48 | }; 49 | ssh: SSHConfig; 50 | } 51 | 52 | export interface CommandHistoryEntry { 53 | command: string; 54 | output: string; 55 | timestamp: string; 56 | exitCode: number; 57 | connectionId?: string; 58 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@simonb97/server-win-cli", 3 | "version": "0.2.1", 4 | "description": "MCP server for Windows CLI interactions", 5 | "type": "module", 6 | "bin": { 7 | "server-win-cli": "./dist/index.js" 8 | }, 9 | "files": [ 10 | "dist" 11 | ], 12 | "license": "MIT", 13 | "author": "Simon Benedict", 14 | "homepage": "https://github.com/SimonB97/win-cli-mcp-server", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/SimonB97/win-cli-mcp-server.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/SimonB97/win-cli-mcp-server/issues" 21 | }, 22 | "keywords": [ 23 | "mcp", 24 | "claude", 25 | "cli", 26 | "windows", 27 | "modelcontextprotocol", 28 | "mcp-server", 29 | "ssh" 30 | ], 31 | "engines": { 32 | "node": ">=18.0.0" 33 | }, 34 | "scripts": { 35 | "build": "tsc && shx chmod +x dist/index.js", 36 | "prepare": "npm run build", 37 | "watch": "tsc --watch", 38 | "start": "node dist/index.js", 39 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", 40 | "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", 41 | "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage" 42 | }, 43 | "dependencies": { 44 | "@modelcontextprotocol/sdk": "1.0.1", 45 | "@types/ssh2": "^1.15.1", 46 | "ssh2": "^1.16.0", 47 | "yargs": "^17.7.2", 48 | "zod": "^3.22.4" 49 | }, 50 | "devDependencies": { 51 | "@jest/globals": "^29.7.0", 52 | "@types/jest": "^29.5.11", 53 | "@types/node": "^20.11.0", 54 | "@types/yargs": "^17.0.33", 55 | "jest": "^29.7.0", 56 | "shx": "^0.3.4", 57 | "ts-jest": "^29.1.1", 58 | "typescript": "^5.3.3" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /config.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "security": { 3 | "maxCommandLength": 1000, 4 | "blockedCommands": [ 5 | "rm", 6 | "del", 7 | "rmdir", 8 | "format", 9 | "shutdown", 10 | "restart", 11 | "reg", 12 | "regedit", 13 | "net", 14 | "netsh", 15 | "takeown", 16 | "icacls" 17 | ], 18 | "blockedArguments": ["-rf", "/f", "/s", "/q"], 19 | "allowedPaths": ["C:\\Users\\YourUsername", "C:\\Projects"], 20 | "restrictWorkingDirectory": true, 21 | "logCommands": true, 22 | "maxHistorySize": 1000, 23 | "commandTimeout": 30, 24 | "enableInjectionProtection": true 25 | }, 26 | "shells": { 27 | "powershell": { 28 | "enabled": true, 29 | "command": "powershell.exe", 30 | "args": ["-NoProfile", "-NonInteractive", "-Command"], 31 | "blockedOperators": ["&", "|", ";", "`"] 32 | }, 33 | "cmd": { 34 | "enabled": true, 35 | "command": "cmd.exe", 36 | "args": ["/c"], 37 | "blockedOperators": ["&", "|", ";", "`"] 38 | }, 39 | "gitbash": { 40 | "enabled": true, 41 | "command": "C:\\Program Files\\Git\\bin\\bash.exe", 42 | "args": ["-c"], 43 | "blockedOperators": ["&", "|", ";", "`"] 44 | } 45 | }, 46 | "ssh": { 47 | "enabled": false, 48 | "defaultTimeout": 30, 49 | "maxConcurrentSessions": 5, 50 | "keepaliveInterval": 10000, 51 | "readyTimeout": 20000, 52 | "connections": { 53 | "raspberry-pi": { 54 | "host": "raspberrypi.local", 55 | "port": 22, 56 | "username": "pi", 57 | "password": "raspberry", 58 | "keepaliveInterval": 10000, 59 | "readyTimeout": 20000 60 | }, 61 | "dev-server": { 62 | "host": "dev.example.com", 63 | "port": 22, 64 | "username": "admin", 65 | "privateKeyPath": "C:\\Users\\YourUsername\\.ssh\\id_rsa", 66 | "keepaliveInterval": 10000, 67 | "readyTimeout": 20000 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/sshManager.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { ServerConfig } from '../types/config.js'; 4 | import { loadConfig as loadMainConfig } from './config.js'; 5 | 6 | /** 7 | * Load the current configuration from the config file. 8 | */ 9 | const loadConfig = (): ServerConfig => { 10 | try { 11 | // Use the same config file that the main application uses 12 | return loadMainConfig(); 13 | } catch (error) { 14 | console.error('Error loading configuration:', error); 15 | throw error; 16 | } 17 | }; 18 | 19 | /** 20 | * Save the updated configuration to the config file. 21 | * @param config The updated configuration object. 22 | */ 23 | const saveConfig = (config: ServerConfig): void => { 24 | try { 25 | // Use the actual config path from the process args or default 26 | const args = process.argv.slice(2); 27 | let configPath = './config.json'; 28 | 29 | // Try to find a config path in the arguments 30 | for (let i = 0; i < args.length - 1; i++) { 31 | if ((args[i] === '--config' || args[i] === '-c') && args[i + 1]) { 32 | configPath = args[i + 1]; 33 | break; 34 | } 35 | } 36 | 37 | // Resolve the path to be safe 38 | const resolvedPath = path.resolve(configPath); 39 | fs.writeFileSync(resolvedPath, JSON.stringify(config, null, 2)); 40 | } catch (error) { 41 | console.error('Error saving configuration:', error); 42 | throw error; 43 | } 44 | }; 45 | 46 | /** 47 | * Create a new SSH connection. 48 | * @param connectionId The ID for the new connection. 49 | * @param connectionConfig The configuration for the new connection. 50 | */ 51 | const createSSHConnection = (connectionId: string, connectionConfig: any): void => { 52 | const config = loadConfig(); 53 | config.ssh.connections[connectionId] = connectionConfig; 54 | saveConfig(config); 55 | }; 56 | 57 | /** 58 | * Read all SSH connections. 59 | * @returns An object containing all SSH connections. 60 | */ 61 | const readSSHConnections = (): object => { 62 | const config = loadConfig(); 63 | return config.ssh.connections; 64 | }; 65 | 66 | /** 67 | * Update an existing SSH connection. 68 | * @param connectionId The ID of the connection to update. 69 | * @param connectionConfig The new configuration for the connection. 70 | */ 71 | const updateSSHConnection = (connectionId: string, connectionConfig: any): void => { 72 | const config = loadConfig(); 73 | if (config.ssh.connections[connectionId]) { 74 | config.ssh.connections[connectionId] = connectionConfig; 75 | saveConfig(config); 76 | } 77 | }; 78 | 79 | /** 80 | * Delete an SSH connection. 81 | * @param connectionId The ID of the connection to delete. 82 | */ 83 | const deleteSSHConnection = (connectionId: string): void => { 84 | const config = loadConfig(); 85 | delete config.ssh.connections[connectionId]; 86 | saveConfig(config); 87 | }; 88 | 89 | export { createSSHConnection, readSSHConnections, updateSSHConnection, deleteSSHConnection }; -------------------------------------------------------------------------------- /src/utils/ssh.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'ssh2'; 2 | import { SSHConnectionConfig } from '../types/config.js'; 3 | import fs from 'fs/promises'; 4 | 5 | export class SSHConnection { 6 | private client: Client; 7 | private config: SSHConnectionConfig; 8 | private isConnected: boolean = false; 9 | private reconnectTimer: NodeJS.Timeout | null = null; 10 | private lastActivity: number = Date.now(); 11 | 12 | constructor(config: SSHConnectionConfig) { 13 | this.client = new Client(); 14 | this.config = config; 15 | this.setupClientEvents(); 16 | } 17 | 18 | private setupClientEvents() { 19 | this.client 20 | .on('error', (err) => { 21 | console.error(`SSH connection error for ${this.config.host}:`, err.message); 22 | this.isConnected = false; 23 | this.scheduleReconnect(); 24 | }) 25 | .on('end', () => { 26 | console.error(`SSH connection ended for ${this.config.host}`); 27 | this.isConnected = false; 28 | this.scheduleReconnect(); 29 | }) 30 | .on('close', () => { 31 | console.error(`SSH connection closed for ${this.config.host}`); 32 | this.isConnected = false; 33 | this.scheduleReconnect(); 34 | }); 35 | } 36 | 37 | private scheduleReconnect() { 38 | if (this.reconnectTimer) { 39 | clearTimeout(this.reconnectTimer); 40 | } 41 | 42 | // Only attempt reconnect if there was recent activity 43 | const timeSinceLastActivity = Date.now() - this.lastActivity; 44 | if (timeSinceLastActivity < 30 * 60 * 1000) { // 30 minutes 45 | this.reconnectTimer = setTimeout(() => { 46 | console.error(`Attempting to reconnect to ${this.config.host}...`); 47 | this.connect().catch(err => { 48 | console.error(`Reconnection failed for ${this.config.host}:`, err.message); 49 | }); 50 | }, 5000); // Wait 5 seconds before reconnecting 51 | } 52 | } 53 | 54 | async connect(): Promise { 55 | if (this.isConnected) { 56 | return; 57 | } 58 | 59 | return new Promise(async (resolve, reject) => { 60 | try { 61 | const connectionConfig: any = { 62 | host: this.config.host, 63 | port: this.config.port, 64 | username: this.config.username, 65 | keepaliveInterval: this.config.keepaliveInterval || 10000, 66 | keepaliveCountMax: this.config.keepaliveCountMax || 3, 67 | readyTimeout: this.config.readyTimeout || 20000, 68 | }; 69 | 70 | // Handle authentication 71 | if (this.config.privateKeyPath) { 72 | const privateKey = await fs.readFile(this.config.privateKeyPath, 'utf8'); 73 | connectionConfig.privateKey = privateKey; 74 | } else if (this.config.password) { 75 | connectionConfig.password = this.config.password; 76 | } else { 77 | throw new Error('No authentication method provided'); 78 | } 79 | 80 | this.client 81 | .on('ready', () => { 82 | this.isConnected = true; 83 | this.lastActivity = Date.now(); 84 | resolve(); 85 | }) 86 | .on('error', (err) => { 87 | reject(err); 88 | }) 89 | .connect(connectionConfig); 90 | } catch (error) { 91 | reject(error); 92 | } 93 | }); 94 | } 95 | 96 | async executeCommand(command: string): Promise<{ output: string; exitCode: number }> { 97 | this.lastActivity = Date.now(); 98 | 99 | // Check connection and attempt reconnect if needed 100 | if (!this.isConnected) { 101 | await this.connect(); 102 | } 103 | 104 | return new Promise((resolve, reject) => { 105 | this.client.exec(command, (err, stream) => { 106 | if (err) { 107 | reject(err); 108 | return; 109 | } 110 | 111 | let output = ''; 112 | let errorOutput = ''; 113 | 114 | stream 115 | .on('data', (data: Buffer) => { 116 | output += data.toString(); 117 | }) 118 | .stderr.on('data', (data: Buffer) => { 119 | errorOutput += data.toString(); 120 | }); 121 | 122 | stream.on('close', (code: number) => { 123 | this.lastActivity = Date.now(); 124 | resolve({ 125 | output: output || errorOutput, 126 | exitCode: code || 0 127 | }); 128 | }); 129 | }); 130 | }); 131 | } 132 | 133 | disconnect(): void { 134 | if (this.reconnectTimer) { 135 | clearTimeout(this.reconnectTimer); 136 | this.reconnectTimer = null; 137 | } 138 | 139 | if (this.isConnected) { 140 | this.client.end(); 141 | this.isConnected = false; 142 | } 143 | } 144 | 145 | isActive(): boolean { 146 | return this.isConnected; 147 | } 148 | } 149 | 150 | // Connection pool to manage multiple SSH connections 151 | export class SSHConnectionPool { 152 | private connections: Map = new Map(); 153 | 154 | async getConnection(connectionId: string, config: SSHConnectionConfig): Promise { 155 | let connection = this.connections.get(connectionId); 156 | 157 | if (!connection) { 158 | connection = new SSHConnection(config); 159 | this.connections.set(connectionId, connection); 160 | await connection.connect(); 161 | } else if (!connection.isActive()) { 162 | await connection.connect(); 163 | } 164 | 165 | return connection; 166 | } 167 | 168 | async closeConnection(connectionId: string): Promise { 169 | const connection = this.connections.get(connectionId); 170 | if (connection) { 171 | connection.disconnect(); 172 | this.connections.delete(connectionId); 173 | } 174 | } 175 | 176 | closeAll(): void { 177 | for (const connection of this.connections.values()) { 178 | connection.disconnect(); 179 | } 180 | this.connections.clear(); 181 | } 182 | } -------------------------------------------------------------------------------- /src/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { exec } from 'child_process'; 3 | import { promisify } from 'util'; 4 | import type { ShellConfig } from '../types/config.js'; 5 | const execAsync = promisify(exec); 6 | 7 | export function extractCommandName(command: string): string { 8 | // Remove any path components 9 | const basename = path.basename(command); 10 | // Remove extension 11 | return basename.replace(/\.(exe|cmd|bat)$/i, '').toLowerCase(); 12 | } 13 | 14 | export function isCommandBlocked(command: string, blockedCommands: string[]): boolean { 15 | const commandName = extractCommandName(command.toLowerCase()); 16 | return blockedCommands.some(blocked => 17 | commandName === blocked.toLowerCase() || 18 | commandName === `${blocked.toLowerCase()}.exe` || 19 | commandName === `${blocked.toLowerCase()}.cmd` || 20 | commandName === `${blocked.toLowerCase()}.bat` 21 | ); 22 | } 23 | 24 | export function isArgumentBlocked(args: string[], blockedArguments: string[]): boolean { 25 | return args.some(arg => 26 | blockedArguments.some(blocked => 27 | new RegExp(`^${blocked}$`, 'i').test(arg) 28 | ) 29 | ); 30 | } 31 | 32 | /** 33 | * Validates a command for a specific shell, checking for shell-specific blocked operators 34 | */ 35 | export function validateShellOperators(command: string, shellConfig: ShellConfig): void { 36 | // Skip validation if shell doesn't specify blocked operators 37 | if (!shellConfig.blockedOperators?.length) { 38 | return; 39 | } 40 | 41 | // Create regex pattern from blocked operators 42 | const operatorPattern = shellConfig.blockedOperators 43 | .map(op => op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) // Escape regex special chars 44 | .join('|'); 45 | 46 | const regex = new RegExp(operatorPattern); 47 | if (regex.test(command)) { 48 | throw new Error(`Command contains blocked operators for this shell: ${shellConfig.blockedOperators.join(', ')}`); 49 | } 50 | } 51 | 52 | /** 53 | * Parse a command string into command and arguments, properly handling paths with spaces and quotes 54 | */ 55 | export function parseCommand(fullCommand: string): { command: string; args: string[] } { 56 | fullCommand = fullCommand.trim(); 57 | if (!fullCommand) { 58 | return { command: '', args: [] }; 59 | } 60 | 61 | const tokens: string[] = []; 62 | let current = ''; 63 | let inQuotes = false; 64 | let quoteChar = ''; 65 | 66 | // Parse into tokens, preserving quoted strings 67 | for (let i = 0; i < fullCommand.length; i++) { 68 | const char = fullCommand[i]; 69 | 70 | // Handle quotes 71 | if ((char === '"' || char === "'") && (!inQuotes || char === quoteChar)) { 72 | if (inQuotes) { 73 | tokens.push(current); 74 | current = ''; 75 | } 76 | inQuotes = !inQuotes; 77 | quoteChar = inQuotes ? char : ''; 78 | continue; 79 | } 80 | 81 | // Handle spaces outside quotes 82 | if (char === ' ' && !inQuotes) { 83 | if (current) { 84 | tokens.push(current); 85 | current = ''; 86 | } 87 | continue; 88 | } 89 | 90 | current += char; 91 | } 92 | 93 | // Add any remaining token 94 | if (current) { 95 | tokens.push(current); 96 | } 97 | 98 | // Handle empty input 99 | if (tokens.length === 0) { 100 | return { command: '', args: [] }; 101 | } 102 | 103 | // First, check if this is a single-token command 104 | if (!tokens[0].includes(' ') && !tokens[0].includes('\\')) { 105 | return { 106 | command: tokens[0], 107 | args: tokens.slice(1) 108 | }; 109 | } 110 | 111 | // Special handling for Windows paths with spaces 112 | let commandTokens: string[] = []; 113 | let i = 0; 114 | 115 | // Keep processing tokens until we find a complete command path 116 | while (i < tokens.length) { 117 | commandTokens.push(tokens[i]); 118 | const potentialCommand = commandTokens.join(' '); 119 | 120 | // Check if this could be a complete command path 121 | if (/\.(exe|cmd|bat)$/i.test(potentialCommand) || 122 | (!potentialCommand.includes('\\') && commandTokens.length === 1)) { 123 | return { 124 | command: potentialCommand, 125 | args: tokens.slice(i + 1) 126 | }; 127 | } 128 | 129 | // If this is part of a path, keep looking 130 | if (potentialCommand.includes('\\')) { 131 | i++; 132 | continue; 133 | } 134 | 135 | // If we get here, treat the first token as the command 136 | return { 137 | command: tokens[0], 138 | args: tokens.slice(1) 139 | }; 140 | } 141 | 142 | // If we get here, use all collected tokens as the command 143 | return { 144 | command: commandTokens.join(' '), 145 | args: tokens.slice(commandTokens.length) 146 | }; 147 | } 148 | 149 | export function isPathAllowed(testPath: string, allowedPaths: string[]): boolean { 150 | const normalizedPath = path.normalize(testPath).toLowerCase(); 151 | return allowedPaths.some(allowedPath => { 152 | const normalizedAllowedPath = path.normalize(allowedPath).toLowerCase(); 153 | return normalizedPath.startsWith(normalizedAllowedPath); 154 | }); 155 | } 156 | 157 | export function validateWorkingDirectory(dir: string, allowedPaths: string[]): void { 158 | if (!path.isAbsolute(dir)) { 159 | throw new Error('Working directory must be an absolute path'); 160 | } 161 | 162 | if (!isPathAllowed(dir, allowedPaths)) { 163 | const allowedPathsStr = allowedPaths.join(', '); 164 | throw new Error( 165 | `Working directory must be within allowed paths: ${allowedPathsStr}` 166 | ); 167 | } 168 | } 169 | 170 | export function normalizeWindowsPath(inputPath: string): string { 171 | // Convert forward slashes to backslashes 172 | let normalized = inputPath.replace(/\//g, '\\'); 173 | 174 | // Handle Windows drive letter 175 | if (/^[a-zA-Z]:\\.+/.test(normalized)) { 176 | // Already in correct form 177 | return path.normalize(normalized); 178 | } 179 | 180 | // Handle paths without drive letter 181 | if (normalized.startsWith('\\')) { 182 | // Assume C: drive if not specified 183 | normalized = `C:${normalized}`; 184 | } 185 | 186 | return path.normalize(normalized); 187 | } -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import os from 'os'; 4 | import { ServerConfig, ShellConfig } from '../types/config.js'; 5 | 6 | const defaultValidatePathRegex = /^[a-zA-Z]:\\(?:[^<>:"/\\|?*]+\\)*[^<>:"/\\|?*]*$/; 7 | 8 | export const DEFAULT_CONFIG: ServerConfig = { 9 | security: { 10 | maxCommandLength: 2000, 11 | blockedCommands: [ 12 | 'rm', 'del', 'rmdir', 'format', 13 | 'shutdown', 'restart', 14 | 'reg', 'regedit', 15 | 'net', 'netsh', 16 | 'takeown', 'icacls' 17 | ], 18 | blockedArguments: [ 19 | "--exec", "-e", "/c", "-enc", "-encodedcommand", 20 | "-command", "--interactive", "-i", "--login", "--system" 21 | ], 22 | allowedPaths: [ 23 | os.homedir(), 24 | process.cwd() 25 | ], 26 | restrictWorkingDirectory: true, 27 | logCommands: true, 28 | maxHistorySize: 1000, 29 | commandTimeout: 30, 30 | enableInjectionProtection: true 31 | }, 32 | shells: { 33 | powershell: { 34 | enabled: true, 35 | command: 'powershell.exe', 36 | args: ['-NoProfile', '-NonInteractive', '-Command'], 37 | validatePath: (dir: string) => dir.match(defaultValidatePathRegex) !== null, 38 | blockedOperators: ['&', '|', ';', '`'] 39 | }, 40 | cmd: { 41 | enabled: true, 42 | command: 'cmd.exe', 43 | args: ['/c'], 44 | validatePath: (dir: string) => dir.match(defaultValidatePathRegex) !== null, 45 | blockedOperators: ['&', '|', ';', '`'] 46 | }, 47 | gitbash: { 48 | enabled: true, 49 | command: 'C:\\Program Files\\Git\\bin\\bash.exe', 50 | args: ['-c'], 51 | validatePath: (dir: string) => dir.match(defaultValidatePathRegex) !== null, 52 | blockedOperators: ['&', '|', ';', '`'] 53 | } 54 | }, 55 | ssh: { 56 | enabled: false, 57 | defaultTimeout: 30, 58 | maxConcurrentSessions: 5, 59 | keepaliveInterval: 10000, 60 | keepaliveCountMax: 3, 61 | readyTimeout: 20000, 62 | connections: {} 63 | } 64 | }; 65 | 66 | export function loadConfig(configPath?: string): ServerConfig { 67 | // If no config path provided, look in default locations 68 | const configLocations = [ 69 | configPath, 70 | path.join(process.cwd(), 'config.json'), 71 | path.join(os.homedir(), '.win-cli-mcp', 'config.json') 72 | ].filter(Boolean); 73 | 74 | let loadedConfig: Partial = {}; 75 | 76 | for (const location of configLocations) { 77 | if (!location) continue; 78 | 79 | try { 80 | if (fs.existsSync(location)) { 81 | const fileContent = fs.readFileSync(location, 'utf8'); 82 | loadedConfig = JSON.parse(fileContent); 83 | console.error(`Loaded config from ${location}`); 84 | break; 85 | } 86 | } catch (error) { 87 | console.error(`Error loading config from ${location}:`, error); 88 | } 89 | } 90 | 91 | // Use defaults only if no config was loaded 92 | const mergedConfig = Object.keys(loadedConfig).length > 0 93 | ? mergeConfigs(DEFAULT_CONFIG, loadedConfig) 94 | : DEFAULT_CONFIG; 95 | 96 | // Validate the merged config 97 | validateConfig(mergedConfig); 98 | 99 | return mergedConfig; 100 | } 101 | 102 | function mergeConfigs(defaultConfig: ServerConfig, userConfig: Partial): ServerConfig { 103 | const merged: ServerConfig = { 104 | security: { 105 | // If user provided security config, use it entirely, otherwise use default 106 | ...(userConfig.security || defaultConfig.security) 107 | }, 108 | shells: { 109 | // Same for each shell - if user provided config, use it entirely 110 | powershell: userConfig.shells?.powershell || defaultConfig.shells.powershell, 111 | cmd: userConfig.shells?.cmd || defaultConfig.shells.cmd, 112 | gitbash: userConfig.shells?.gitbash || defaultConfig.shells.gitbash 113 | }, 114 | ssh: { 115 | // Merge SSH config 116 | ...(defaultConfig.ssh), 117 | ...(userConfig.ssh || {}), 118 | // Ensure connections are merged 119 | connections: { 120 | ...(defaultConfig.ssh.connections), 121 | ...(userConfig.ssh?.connections || {}) 122 | } 123 | } 124 | }; 125 | 126 | // Only add validatePath functions and blocked operators if they don't exist 127 | for (const [key, shell] of Object.entries(merged.shells) as [keyof typeof merged.shells, ShellConfig][]) { 128 | if (!shell.validatePath) { 129 | shell.validatePath = defaultConfig.shells[key].validatePath; 130 | } 131 | if (!shell.blockedOperators) { 132 | shell.blockedOperators = defaultConfig.shells[key].blockedOperators; 133 | } 134 | } 135 | 136 | return merged; 137 | } 138 | 139 | function validateConfig(config: ServerConfig): void { 140 | // Validate security settings 141 | if (config.security.maxCommandLength < 1) { 142 | throw new Error('maxCommandLength must be positive'); 143 | } 144 | 145 | if (config.security.maxHistorySize < 1) { 146 | throw new Error('maxHistorySize must be positive'); 147 | } 148 | 149 | // Validate shell configurations 150 | for (const [shellName, shell] of Object.entries(config.shells)) { 151 | if (shell.enabled && (!shell.command || !shell.args)) { 152 | throw new Error(`Invalid configuration for ${shellName}: missing command or args`); 153 | } 154 | } 155 | 156 | // Validate timeout (minimum 1 second) 157 | if (config.security.commandTimeout < 1) { 158 | throw new Error('commandTimeout must be at least 1 second'); 159 | } 160 | 161 | // Validate SSH configuration 162 | if (config.ssh.enabled) { 163 | if (config.ssh.defaultTimeout < 1) { 164 | throw new Error('SSH defaultTimeout must be at least 1 second'); 165 | } 166 | if (config.ssh.maxConcurrentSessions < 1) { 167 | throw new Error('SSH maxConcurrentSessions must be at least 1'); 168 | } 169 | if (config.ssh.keepaliveInterval < 1000) { 170 | throw new Error('SSH keepaliveInterval must be at least 1000ms'); 171 | } 172 | if (config.ssh.readyTimeout < 1000) { 173 | throw new Error('SSH readyTimeout must be at least 1000ms'); 174 | } 175 | 176 | // Validate individual connections 177 | for (const [connId, conn] of Object.entries(config.ssh.connections)) { 178 | if (!conn.host || !conn.username || (!conn.password && !conn.privateKeyPath)) { 179 | throw new Error(`Invalid SSH connection config for '${connId}': missing required fields`); 180 | } 181 | if (conn.port && (conn.port < 1 || conn.port > 65535)) { 182 | throw new Error(`Invalid SSH port for '${connId}': must be between 1 and 65535`); 183 | } 184 | } 185 | } 186 | } 187 | 188 | // Helper function to create a default config file 189 | export function createDefaultConfig(configPath: string): void { 190 | const dirPath = path.dirname(configPath); 191 | 192 | if (!fs.existsSync(dirPath)) { 193 | fs.mkdirSync(dirPath, { recursive: true }); 194 | } 195 | 196 | // Create a JSON-safe version of the config (excluding functions) 197 | const configForSave = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); 198 | fs.writeFileSync(configPath, JSON.stringify(configForSave, null, 2)); 199 | } -------------------------------------------------------------------------------- /tests/validation.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, jest } from '@jest/globals'; 2 | import path from 'path'; 3 | import { 4 | extractCommandName, 5 | isCommandBlocked, 6 | isArgumentBlocked, 7 | parseCommand, 8 | isPathAllowed, 9 | validateWorkingDirectory, 10 | normalizeWindowsPath, 11 | validateShellOperators 12 | } from '../src/utils/validation.js'; 13 | import type { ShellConfig } from '../src/types/config.js'; 14 | 15 | // Mock child_process exec 16 | jest.mock('child_process', () => ({ 17 | exec: jest.fn() 18 | })); 19 | 20 | 21 | describe('Command Name Extraction', () => { 22 | test('extractCommandName handles various formats', () => { 23 | expect(extractCommandName('cmd.exe')).toBe('cmd'); 24 | expect(extractCommandName('C:\\Windows\\System32\\cmd.exe')).toBe('cmd'); 25 | expect(extractCommandName('powershell.exe')).toBe('powershell'); 26 | expect(extractCommandName('git.cmd')).toBe('git'); 27 | expect(extractCommandName('program')).toBe('program'); 28 | expect(extractCommandName('path/to/script.bat')).toBe('script'); 29 | }); 30 | 31 | test('extractCommandName is case insensitive', () => { 32 | expect(extractCommandName('CMD.EXE')).toBe('cmd'); 33 | expect(extractCommandName('PowerShell.Exe')).toBe('powershell'); 34 | }); 35 | }); 36 | 37 | describe('Command Blocking', () => { 38 | const blockedCommands = ['rm', 'del', 'format']; 39 | 40 | test('isCommandBlocked identifies blocked commands', () => { 41 | expect(isCommandBlocked('rm', blockedCommands)).toBe(true); 42 | expect(isCommandBlocked('rm.exe', blockedCommands)).toBe(true); 43 | expect(isCommandBlocked('C:\\Windows\\System32\\rm.exe', blockedCommands)).toBe(true); 44 | expect(isCommandBlocked('notepad.exe', blockedCommands)).toBe(false); 45 | }); 46 | 47 | test('isCommandBlocked is case insensitive', () => { 48 | expect(isCommandBlocked('RM.exe', blockedCommands)).toBe(true); 49 | expect(isCommandBlocked('DeL.exe', blockedCommands)).toBe(true); 50 | expect(isCommandBlocked('FORMAT.EXE', blockedCommands)).toBe(true); 51 | }); 52 | 53 | test('isCommandBlocked handles different extensions', () => { 54 | expect(isCommandBlocked('rm.cmd', blockedCommands)).toBe(true); 55 | expect(isCommandBlocked('del.bat', blockedCommands)).toBe(true); 56 | expect(isCommandBlocked('format.com', blockedCommands)).toBe(false); // Should only match .exe, .cmd, .bat 57 | }); 58 | }); 59 | 60 | describe('Argument Blocking', () => { 61 | const blockedArgs = ['--system', '-rf', '--exec']; 62 | 63 | test('isArgumentBlocked identifies blocked arguments', () => { 64 | expect(isArgumentBlocked(['--help', '--system'], blockedArgs)).toBe(true); 65 | expect(isArgumentBlocked(['-rf'], blockedArgs)).toBe(true); 66 | expect(isArgumentBlocked(['--safe', '--normal'], blockedArgs)).toBe(false); 67 | }); 68 | 69 | test('isArgumentBlocked is case insensitive for security', () => { 70 | expect(isArgumentBlocked(['--SYSTEM'], blockedArgs)).toBe(true); 71 | expect(isArgumentBlocked(['-RF'], blockedArgs)).toBe(true); 72 | expect(isArgumentBlocked(['--SyStEm'], blockedArgs)).toBe(true); 73 | }); 74 | 75 | test('isArgumentBlocked handles multiple arguments', () => { 76 | expect(isArgumentBlocked(['--safe', '--exec', '--other'], blockedArgs)).toBe(true); 77 | expect(isArgumentBlocked(['arg1', 'arg2', '--help'], blockedArgs)).toBe(false); 78 | }); 79 | }); 80 | 81 | describe('Command Parsing', () => { 82 | test('parseCommand handles basic commands', () => { 83 | expect(parseCommand('dir')).toEqual({ command: 'dir', args: [] }); 84 | expect(parseCommand('echo hello')).toEqual({ command: 'echo', args: ['hello'] }); 85 | }); 86 | 87 | test('parseCommand handles quoted arguments', () => { 88 | expect(parseCommand('echo "hello world"')).toEqual({ 89 | command: 'echo', 90 | args: ['hello world'] 91 | }); 92 | expect(parseCommand('echo "first" "second"')).toEqual({ 93 | command: 'echo', 94 | args: ['first', 'second'] 95 | }); 96 | }); 97 | 98 | test('parseCommand handles paths with spaces', () => { 99 | expect(parseCommand('C:\\Program Files\\Git\\bin\\git.exe status')).toEqual({ 100 | command: 'C:\\Program Files\\Git\\bin\\git.exe', 101 | args: ['status'] 102 | }); 103 | }); 104 | 105 | test('parseCommand handles empty input', () => { 106 | expect(parseCommand('')).toEqual({ command: '', args: [] }); 107 | expect(parseCommand(' ')).toEqual({ command: '', args: [] }); 108 | }); 109 | 110 | test('parseCommand handles mixed quotes', () => { 111 | expect(parseCommand('git commit -m "first commit" --author="John Doe"')).toEqual({ 112 | command: 'git', 113 | args: ['commit', '-m', 'first commit', '--author=John Doe'] 114 | }); 115 | }); 116 | }); 117 | 118 | describe('Path Validation', () => { 119 | const allowedPaths = [ 120 | 'C:\\Users\\test', 121 | 'D:\\Projects' 122 | ]; 123 | 124 | test('isPathAllowed validates paths correctly', () => { 125 | expect(isPathAllowed('C:\\Users\\test\\docs', allowedPaths)).toBe(true); 126 | expect(isPathAllowed('C:\\Users\\test', allowedPaths)).toBe(true); 127 | expect(isPathAllowed('D:\\Projects\\code', allowedPaths)).toBe(true); 128 | expect(isPathAllowed('E:\\NotAllowed', allowedPaths)).toBe(false); 129 | }); 130 | 131 | test('isPathAllowed is case insensitive', () => { 132 | expect(isPathAllowed('c:\\users\\TEST\\docs', allowedPaths)).toBe(true); 133 | expect(isPathAllowed('D:\\PROJECTS\\code', allowedPaths)).toBe(true); 134 | }); 135 | 136 | test('validateWorkingDirectory throws for invalid paths', () => { 137 | expect(() => validateWorkingDirectory('relative/path', allowedPaths)) 138 | .toThrow('Working directory must be an absolute path'); 139 | expect(() => validateWorkingDirectory('E:\\NotAllowed', allowedPaths)) 140 | .toThrow('Working directory must be within allowed paths'); 141 | }); 142 | }); 143 | 144 | describe('Path Normalization', () => { 145 | test('normalizeWindowsPath handles various formats', () => { 146 | expect(normalizeWindowsPath('C:/Users/test')).toBe('C:\\Users\\test'); 147 | expect(normalizeWindowsPath('\\Users\\test')).toBe('C:\\Users\\test'); 148 | expect(normalizeWindowsPath('D:\\Projects')).toBe('D:\\Projects'); 149 | }); 150 | 151 | test('normalizeWindowsPath removes redundant separators', () => { 152 | expect(normalizeWindowsPath('C:\\\\Users\\\\test')).toBe('C:\\Users\\test'); 153 | expect(normalizeWindowsPath('C:/Users//test')).toBe('C:\\Users\\test'); 154 | }); 155 | }); 156 | 157 | describe('Shell Operator Validation', () => { 158 | const powershellConfig: ShellConfig = { 159 | enabled: true, 160 | command: 'powershell.exe', 161 | args: ['-Command'], 162 | blockedOperators: ['&', ';', '`'] 163 | }; 164 | 165 | test('validateShellOperators blocks dangerous operators', () => { 166 | expect(() => validateShellOperators('Get-Process & Get-Service', powershellConfig)) 167 | .toThrow(); 168 | expect(() => validateShellOperators('Get-Process; Start-Sleep', powershellConfig)) 169 | .toThrow(); 170 | }); 171 | 172 | test('validateShellOperators allows safe operators when configured', () => { 173 | expect(() => validateShellOperators('Get-Process | Select-Object Name', powershellConfig)) 174 | .not.toThrow(); 175 | expect(() => validateShellOperators('$var = Get-Process', powershellConfig)) 176 | .not.toThrow(); 177 | }); 178 | 179 | test('validateShellOperators respects shell config', () => { 180 | const customConfig: ShellConfig = { 181 | enabled: true, 182 | command: 'custom.exe', 183 | args: [], 184 | blockedOperators: ['|'] // Block only pipe operator 185 | }; 186 | 187 | expect(() => validateShellOperators('cmd & echo test', customConfig)) 188 | .not.toThrow(); 189 | expect(() => validateShellOperators('cmd | echo test', customConfig)) 190 | .toThrow(); 191 | }); 192 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Windows CLI MCP Server 2 | 3 | > [!CAUTION] 4 | > **PROJECT DEPRECATED** - No longer maintained. 5 | > Use https://github.com/wonderwhy-er/DesktopCommanderMCP instead for similar functionality. 6 | 7 | [![NPM Downloads](https://img.shields.io/npm/dt/@simonb97/server-win-cli.svg?style=flat)](https://www.npmjs.com/package/@simonb97/server-win-cli) 8 | [![NPM Version](https://img.shields.io/npm/v/@simonb97/server-win-cli.svg?style=flat)](https://www.npmjs.com/package/@simonb97/server-win-cli?activeTab=versions) 9 | [![smithery badge](https://smithery.ai/badge/@simonb97/server-win-cli)](https://smithery.ai/server/@simonb97/server-win-cli) 10 | 11 | [MCP server](https://modelcontextprotocol.io/introduction) for secure command-line interactions on Windows systems, enabling controlled access to PowerShell, CMD, Git Bash shells, and remote systems via SSH. It allows MCP clients (like [Claude Desktop](https://claude.ai/download)) to perform operations on your system, similar to [Open Interpreter](https://github.com/OpenInterpreter/open-interpreter). 12 | 13 | >[!IMPORTANT] 14 | > This MCP server provides direct access to your system's command line interface and remote systems via SSH. When enabled, it grants access to your files, environment variables, command execution capabilities, and remote server management. 15 | > 16 | > - Review and restrict allowed paths and SSH connections 17 | > - Enable directory restrictions 18 | > - Configure command blocks 19 | > - Consider security implications 20 | > 21 | > See [Configuration](#configuration) for more details. 22 | 23 | - [Features](#features) 24 | - [Usage with Claude Desktop](#usage-with-claude-desktop) 25 | - [Configuration](#configuration) 26 | - [Configuration Locations](#configuration-locations) 27 | - [Default Configuration](#default-configuration) 28 | - [Configuration Settings](#configuration-settings) 29 | - [Security Settings](#security-settings) 30 | - [Shell Configuration](#shell-configuration) 31 | - [SSH Configuration](#ssh-configuration) 32 | - [API](#api) 33 | - [Tools](#tools) 34 | - [Resources](#resources) 35 | - [Security Considerations](#security-considerations) 36 | - [License](#license) 37 | 38 | ## Features 39 | 40 | - **Multi-Shell Support**: Execute commands in PowerShell, Command Prompt (CMD), and Git Bash 41 | - **SSH Support**: Execute commands on remote systems via SSH 42 | - **Resource Exposure**: View SSH connections, current directory, and configuration as MCP resources 43 | - **Security Controls**: 44 | - Command and SSH command blocking (full paths, case variations) 45 | - Working directory validation 46 | - Maximum command length limits 47 | - Command logging and history tracking 48 | - Smart argument validation 49 | - **Configurable**: 50 | - Custom security rules 51 | - Shell-specific settings 52 | - SSH connection profiles 53 | - Path restrictions 54 | - Blocked command lists 55 | 56 | See the [API](#api) section for more details on the tools and resources the server provides to MCP clients. 57 | 58 | **Note**: The server will only allow operations within configured directories, with allowed commands, and on configured SSH connections. 59 | 60 | ## Usage with Claude Desktop 61 | 62 | Add this to your `claude_desktop_config.json`: 63 | 64 | ```json 65 | { 66 | "mcpServers": { 67 | "windows-cli": { 68 | "command": "npx", 69 | "args": ["-y", "@simonb97/server-win-cli"] 70 | } 71 | } 72 | } 73 | ``` 74 | 75 | For use with a specific config file, add the `--config` flag: 76 | 77 | ```json 78 | { 79 | "mcpServers": { 80 | "windows-cli": { 81 | "command": "npx", 82 | "args": [ 83 | "-y", 84 | "@simonb97/server-win-cli", 85 | "--config", 86 | "path/to/your/config.json" 87 | ] 88 | } 89 | } 90 | } 91 | ``` 92 | 93 | After configuring, you can: 94 | - Execute commands directly using the available tools 95 | - View configured SSH connections and server configuration in the Resources section 96 | - Manage SSH connections through the provided tools 97 | 98 | ## Configuration 99 | 100 | The server uses a JSON configuration file to customize its behavior. You can specify settings for security controls, shell configurations, and SSH connections. 101 | 102 | 1. To create a default config file, either: 103 | 104 | **a)** copy `config.json.example` to `config.json`, or 105 | 106 | **b)** run: 107 | 108 | ```bash 109 | npx @simonb97/server-win-cli --init-config ./config.json 110 | ``` 111 | 112 | 2. Then set the `--config` flag to point to your config file as described in the [Usage with Claude Desktop](#usage-with-claude-desktop) section. 113 | 114 | ### Configuration Locations 115 | 116 | The server looks for configuration in the following locations (in order): 117 | 118 | 1. Path specified by `--config` flag 119 | 2. ./config.json in current directory 120 | 3. ~/.win-cli-mcp/config.json in user's home directory 121 | 122 | If no configuration file is found, the server will use a default (restricted) configuration: 123 | 124 | ### Default Configuration 125 | 126 | **Note**: The default configuration is designed to be restrictive and secure. Find more details on each setting in the [Configuration Settings](#configuration-settings) section. 127 | 128 | ```json 129 | { 130 | "security": { 131 | "maxCommandLength": 2000, 132 | "blockedCommands": [ 133 | "rm", 134 | "del", 135 | "rmdir", 136 | "format", 137 | "shutdown", 138 | "restart", 139 | "reg", 140 | "regedit", 141 | "net", 142 | "netsh", 143 | "takeown", 144 | "icacls" 145 | ], 146 | "blockedArguments": [ 147 | "--exec", 148 | "-e", 149 | "/c", 150 | "-enc", 151 | "-encodedcommand", 152 | "-command", 153 | "--interactive", 154 | "-i", 155 | "--login", 156 | "--system" 157 | ], 158 | "allowedPaths": ["User's home directory", "Current working directory"], 159 | "restrictWorkingDirectory": true, 160 | "logCommands": true, 161 | "maxHistorySize": 1000, 162 | "commandTimeout": 30, 163 | "enableInjectionProtection": true 164 | }, 165 | "shells": { 166 | "powershell": { 167 | "enabled": true, 168 | "command": "powershell.exe", 169 | "args": ["-NoProfile", "-NonInteractive", "-Command"], 170 | "blockedOperators": ["&", "|", ";", "`"] 171 | }, 172 | "cmd": { 173 | "enabled": true, 174 | "command": "cmd.exe", 175 | "args": ["/c"], 176 | "blockedOperators": ["&", "|", ";", "`"] 177 | }, 178 | "gitbash": { 179 | "enabled": true, 180 | "command": "C:\\Program Files\\Git\\bin\\bash.exe", 181 | "args": ["-c"], 182 | "blockedOperators": ["&", "|", ";", "`"] 183 | } 184 | }, 185 | "ssh": { 186 | "enabled": false, 187 | "defaultTimeout": 30, 188 | "maxConcurrentSessions": 5, 189 | "keepaliveInterval": 10000, 190 | "keepaliveCountMax": 3, 191 | "readyTimeout": 20000, 192 | "connections": {} 193 | } 194 | } 195 | ``` 196 | 197 | ### Configuration Settings 198 | 199 | The configuration file is divided into three main sections: `security`, `shells`, and `ssh`. 200 | 201 | #### Security Settings 202 | 203 | ```json 204 | { 205 | "security": { 206 | // Maximum allowed length for any command 207 | "maxCommandLength": 1000, 208 | 209 | // Commands to block - blocks both direct use and full paths 210 | // Example: "rm" blocks both "rm" and "C:\\Windows\\System32\\rm.exe" 211 | // Case-insensitive: "del" blocks "DEL.EXE", "del.cmd", etc. 212 | "blockedCommands": [ 213 | "rm", // Delete files 214 | "del", // Delete files 215 | "rmdir", // Delete directories 216 | "format", // Format disks 217 | "shutdown", // Shutdown system 218 | "restart", // Restart system 219 | "reg", // Registry editor 220 | "regedit", // Registry editor 221 | "net", // Network commands 222 | "netsh", // Network commands 223 | "takeown", // Take ownership of files 224 | "icacls" // Change file permissions 225 | ], 226 | 227 | // Arguments that will be blocked when used with any command 228 | // Note: Checks each argument independently - "cd warm_dir" won't be blocked just because "rm" is in blockedCommands 229 | "blockedArguments": [ 230 | "--exec", // Execution flags 231 | "-e", // Short execution flags 232 | "/c", // Command execution in some shells 233 | "-enc", // PowerShell encoded commands 234 | "-encodedcommand", // PowerShell encoded commands 235 | "-command", // Direct PowerShell command execution 236 | "--interactive", // Interactive mode which might bypass restrictions 237 | "-i", // Short form of interactive 238 | "--login", // Login shells might have different permissions 239 | "--system" // System level operations 240 | ], 241 | 242 | // List of directories where commands can be executed 243 | "allowedPaths": ["C:\\Users\\YourUsername", "C:\\Projects"], 244 | 245 | // If true, commands can only run in allowedPaths 246 | "restrictWorkingDirectory": true, 247 | 248 | // If true, saves command history 249 | "logCommands": true, 250 | 251 | // Maximum number of commands to keep in history 252 | "maxHistorySize": 1000, 253 | 254 | // Timeout for command execution in seconds (default: 30) 255 | "commandTimeout": 30, 256 | 257 | // Enable or disable protection against command injection (covers ;, &, |, \`) 258 | "enableInjectionProtection": true 259 | } 260 | } 261 | ``` 262 | 263 | #### Shell Configuration 264 | 265 | ```json 266 | { 267 | "shells": { 268 | "powershell": { 269 | // Enable/disable this shell 270 | "enabled": true, 271 | // Path to shell executable 272 | "command": "powershell.exe", 273 | // Default arguments for the shell 274 | "args": ["-NoProfile", "-NonInteractive", "-Command"], 275 | // Optional: Specify which command operators to block 276 | "blockedOperators": ["&", "|", ";", "`"] // Block all command chaining 277 | }, 278 | "cmd": { 279 | "enabled": true, 280 | "command": "cmd.exe", 281 | "args": ["/c"], 282 | "blockedOperators": ["&", "|", ";", "`"] // Block all command chaining 283 | }, 284 | "gitbash": { 285 | "enabled": true, 286 | "command": "C:\\Program Files\\Git\\bin\\bash.exe", 287 | "args": ["-c"], 288 | "blockedOperators": ["&", "|", ";", "`"] // Block all command chaining 289 | } 290 | } 291 | } 292 | ``` 293 | 294 | #### SSH Configuration 295 | 296 | ```json 297 | { 298 | "ssh": { 299 | // Enable/disable SSH functionality 300 | "enabled": false, 301 | 302 | // Default timeout for SSH commands in seconds 303 | "defaultTimeout": 30, 304 | 305 | // Maximum number of concurrent SSH sessions 306 | "maxConcurrentSessions": 5, 307 | 308 | // Interval for sending keepalive packets (in milliseconds) 309 | "keepaliveInterval": 10000, 310 | 311 | // Maximum number of failed keepalive attempts before disconnecting 312 | "keepaliveCountMax": 3, 313 | 314 | // Timeout for establishing SSH connections (in milliseconds) 315 | "readyTimeout": 20000, 316 | 317 | // SSH connection profiles 318 | "connections": { 319 | // NOTE: these examples are not set in the default config! 320 | // Example: Local Raspberry Pi 321 | "raspberry-pi": { 322 | "host": "raspberrypi.local", // Hostname or IP address 323 | "port": 22, // SSH port 324 | "username": "pi", // SSH username 325 | "password": "raspberry", // Password authentication (if not using key) 326 | "keepaliveInterval": 10000, // Override global keepaliveInterval 327 | "keepaliveCountMax": 3, // Override global keepaliveCountMax 328 | "readyTimeout": 20000 // Override global readyTimeout 329 | }, 330 | // Example: Remote server with key authentication 331 | "dev-server": { 332 | "host": "dev.example.com", 333 | "port": 22, 334 | "username": "admin", 335 | "privateKeyPath": "C:\\Users\\YourUsername\\.ssh\\id_rsa", // Path to private key 336 | "keepaliveInterval": 10000, 337 | "keepaliveCountMax": 3, 338 | "readyTimeout": 20000 339 | } 340 | } 341 | } 342 | } 343 | ``` 344 | 345 | ## API 346 | 347 | ### Tools 348 | 349 | - **execute_command** 350 | 351 | - Execute a command in the specified shell 352 | - Inputs: 353 | - `shell` (string): Shell to use ("powershell", "cmd", or "gitbash") 354 | - `command` (string): Command to execute 355 | - `workingDir` (optional string): Working directory 356 | - Returns command output as text, or error message if execution fails 357 | 358 | - **get_command_history** 359 | 360 | - Get the history of executed commands 361 | - Input: `limit` (optional number) 362 | - Returns timestamped command history with outputs 363 | 364 | - **ssh_execute** 365 | 366 | - Execute a command on a remote system via SSH 367 | - Inputs: 368 | - `connectionId` (string): ID of the SSH connection to use 369 | - `command` (string): Command to execute 370 | - Returns command output as text, or error message if execution fails 371 | 372 | - **ssh_disconnect** 373 | - Disconnect from an SSH server 374 | - Input: 375 | - `connectionId` (string): ID of the SSH connection to disconnect 376 | - Returns confirmation message 377 | 378 | - **create_ssh_connection** 379 | - Create a new SSH connection 380 | - Inputs: 381 | - `connectionId` (string): ID for the new SSH connection 382 | - `connectionConfig` (object): Connection configuration details including host, port, username, and either password or privateKeyPath 383 | - Returns confirmation message 384 | 385 | - **read_ssh_connections** 386 | - Read all configured SSH connections 387 | - Returns a list of all SSH connections from the configuration 388 | 389 | - **update_ssh_connection** 390 | - Update an existing SSH connection 391 | - Inputs: 392 | - `connectionId` (string): ID of the SSH connection to update 393 | - `connectionConfig` (object): New connection configuration details 394 | - Returns confirmation message 395 | 396 | - **delete_ssh_connection** 397 | - Delete an SSH connection 398 | - Input: 399 | - `connectionId` (string): ID of the SSH connection to delete 400 | - Returns confirmation message 401 | 402 | - **get_current_directory** 403 | - Get the current working directory of the server 404 | - Returns the current working directory path 405 | 406 | ### Resources 407 | 408 | - **SSH Connections** 409 | - URI format: `ssh://{connectionId}` 410 | - Contains connection details with sensitive information masked 411 | - One resource for each configured SSH connection 412 | - Example: `ssh://raspberry-pi` shows configuration for the "raspberry-pi" connection 413 | 414 | - **SSH Configuration** 415 | - URI: `ssh://config` 416 | - Contains overall SSH configuration and all connections (with passwords masked) 417 | - Shows settings like defaultTimeout, maxConcurrentSessions, and the list of connections 418 | 419 | - **Current Directory** 420 | - URI: `cli://currentdir` 421 | - Contains the current working directory of the CLI server 422 | - Shows the path where commands will execute by default 423 | 424 | - **CLI Configuration** 425 | - URI: `cli://config` 426 | - Contains the CLI server configuration (excluding sensitive data) 427 | - Shows security settings, shell configurations, and SSH settings 428 | 429 | ## Security Considerations 430 | 431 | ### Built-in Security Features (Always Active) 432 | 433 | The following security features are hard-coded into the server and cannot be disabled: 434 | 435 | - **Case-insensitive command blocking**: All command blocking is case-insensitive (e.g., "DEL.EXE", "del.cmd", etc. are all blocked if "del" is in blockedCommands) 436 | - **Smart path parsing**: The server parses full command paths to prevent bypass attempts (blocking "C:\\Windows\\System32\\rm.exe" if "rm" is blocked) 437 | - **Command parsing intelligence**: False positives are avoided (e.g., "warm_dir" is not blocked just because "rm" is in blockedCommands) 438 | - **Input validation**: All user inputs are validated before execution 439 | - **Shell process management**: Processes are properly terminated after execution or timeout 440 | - **Sensitive data masking**: Passwords are automatically masked in resources (replaced with ********) 441 | 442 | ### Configurable Security Features (Active by Default) 443 | 444 | These security features are configurable through the config.json file: 445 | 446 | - **Command blocking**: Commands specified in `blockedCommands` array are blocked (default includes dangerous commands like rm, del, format) 447 | - **Argument blocking**: Arguments specified in `blockedArguments` array are blocked (default includes potentially dangerous flags) 448 | - **Command injection protection**: Prevents command chaining (enabled by default through `enableInjectionProtection: true`) 449 | - **Working directory restriction**: Limits command execution to specified directories (enabled by default through `restrictWorkingDirectory: true`) 450 | - **Command length limit**: Restricts maximum command length (default: 2000 characters) 451 | - **Command timeout**: Terminates commands that run too long (default: 30 seconds) 452 | - **Command logging**: Records command history (enabled by default through `logCommands: true`) 453 | 454 | ### Important Security Warnings 455 | 456 | These are not features but important security considerations to be aware of: 457 | 458 | - **Environment access**: Commands may have access to environment variables, which could contain sensitive information 459 | - **File system access**: Commands can read/write files within allowed paths - carefully configure `allowedPaths` to prevent access to sensitive data 460 | 461 | ## License 462 | 463 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 464 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 4 | import { 5 | CallToolRequestSchema, 6 | ListToolsRequestSchema, 7 | ListResourcesRequestSchema, 8 | ReadResourceRequestSchema, 9 | ErrorCode, 10 | McpError, 11 | } from "@modelcontextprotocol/sdk/types.js"; 12 | import { 13 | isCommandBlocked, 14 | isArgumentBlocked, 15 | parseCommand, 16 | extractCommandName, 17 | validateShellOperators 18 | } from './utils/validation.js'; 19 | import { spawn } from 'child_process'; 20 | import { z } from 'zod'; 21 | import path from 'path'; 22 | import { loadConfig, createDefaultConfig } from './utils/config.js'; 23 | import type { ServerConfig, CommandHistoryEntry, SSHConnectionConfig } from './types/config.js'; 24 | import { SSHConnectionPool } from './utils/ssh.js'; 25 | import { createRequire } from 'module'; 26 | import { createSSHConnection, readSSHConnections, updateSSHConnection, deleteSSHConnection } from './utils/sshManager.js'; 27 | const require = createRequire(import.meta.url); 28 | const packageJson = require('../package.json'); 29 | 30 | // Parse command line arguments using yargs 31 | import yargs from 'yargs/yargs'; 32 | import { hideBin } from 'yargs/helpers'; 33 | 34 | const parseArgs = async () => { 35 | return yargs(hideBin(process.argv)) 36 | .option('config', { 37 | alias: 'c', 38 | type: 'string', 39 | description: 'Path to config file' 40 | }) 41 | .option('init-config', { 42 | type: 'string', 43 | description: 'Create a default config file at the specified path' 44 | }) 45 | .help() 46 | .parse(); 47 | }; 48 | 49 | class CLIServer { 50 | private server: Server; 51 | private allowedPaths: Set; 52 | private blockedCommands: Set; 53 | private commandHistory: CommandHistoryEntry[]; 54 | private config: ServerConfig; 55 | private sshPool: SSHConnectionPool; 56 | 57 | constructor(config: ServerConfig) { 58 | this.config = config; 59 | this.server = new Server({ 60 | name: "windows-cli-server", 61 | version: packageJson.version, 62 | }, { 63 | capabilities: { 64 | tools: {}, 65 | resources: {} // Add resources capability 66 | } 67 | }); 68 | 69 | // Initialize from config 70 | this.allowedPaths = new Set(config.security.allowedPaths); 71 | this.blockedCommands = new Set(config.security.blockedCommands); 72 | this.commandHistory = []; 73 | this.sshPool = new SSHConnectionPool(); 74 | 75 | this.setupHandlers(); 76 | } 77 | 78 | private validateCommand(shell: keyof ServerConfig['shells'], command: string): void { 79 | // Check for command chaining/injection attempts if enabled 80 | if (this.config.security.enableInjectionProtection) { 81 | // Get shell-specific config 82 | const shellConfig = this.config.shells[shell]; 83 | 84 | // Use shell-specific operator validation 85 | validateShellOperators(command, shellConfig); 86 | } 87 | 88 | const { command: executable, args } = parseCommand(command); 89 | 90 | // Check for blocked commands 91 | if (isCommandBlocked(executable, Array.from(this.blockedCommands))) { 92 | throw new McpError( 93 | ErrorCode.InvalidRequest, 94 | `Command is blocked: "${extractCommandName(executable)}"` 95 | ); 96 | } 97 | 98 | // Check for blocked arguments 99 | if (isArgumentBlocked(args, this.config.security.blockedArguments)) { 100 | throw new McpError( 101 | ErrorCode.InvalidRequest, 102 | 'One or more arguments are blocked. Check configuration for blocked patterns.' 103 | ); 104 | } 105 | 106 | // Validate command length 107 | if (command.length > this.config.security.maxCommandLength) { 108 | throw new McpError( 109 | ErrorCode.InvalidRequest, 110 | `Command exceeds maximum length of ${this.config.security.maxCommandLength}` 111 | ); 112 | } 113 | } 114 | 115 | /** 116 | * Escapes special characters in a string for use in a regular expression 117 | * @param text The string to escape 118 | * @returns The escaped string 119 | */ 120 | private escapeRegex(text: string): string { 121 | return text.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); 122 | } 123 | 124 | private setupHandlers(): void { 125 | // List available resources 126 | this.server.setRequestHandler(ListResourcesRequestSchema, async () => { 127 | const sshConnections = readSSHConnections() as Record; 128 | 129 | // Create resources for each SSH connection 130 | const resources = Object.entries(sshConnections).map(([id, config]) => ({ 131 | uri: `ssh://${id}`, 132 | name: `SSH Connection: ${id}`, 133 | description: `SSH connection to ${config.host}:${config.port} as ${config.username}`, 134 | mimeType: "application/json" 135 | })); 136 | 137 | // Add a resource for the current working directory 138 | resources.push({ 139 | uri: "cli://currentdir", 140 | name: "Current Working Directory", 141 | description: "The current working directory of the CLI server", 142 | mimeType: "text/plain" 143 | }); 144 | 145 | // Add a resource for SSH configuration 146 | resources.push({ 147 | uri: "ssh://config", 148 | name: "SSH Configuration", 149 | description: "All SSH connection configurations", 150 | mimeType: "application/json" 151 | }); 152 | 153 | // Add a resource for CLI configuration 154 | resources.push({ 155 | uri: "cli://config", 156 | name: "CLI Server Configuration", 157 | description: "Main CLI server configuration (excluding sensitive data)", 158 | mimeType: "application/json" 159 | }); 160 | 161 | return { resources }; 162 | }); 163 | 164 | // Read resource content 165 | this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { 166 | const uri = request.params.uri; 167 | 168 | // Handle SSH connection resources 169 | if (uri.startsWith("ssh://") && uri !== "ssh://config") { 170 | const connectionId = uri.slice(6); // Remove "ssh://" prefix 171 | const connections = readSSHConnections() as Record; 172 | const connectionConfig = connections[connectionId]; 173 | 174 | if (!connectionConfig) { 175 | throw new McpError( 176 | ErrorCode.InvalidRequest, 177 | `Unknown SSH connection: ${connectionId}` 178 | ); 179 | } 180 | 181 | // Return connection details (excluding sensitive info) 182 | const safeConfig = { ...connectionConfig }; 183 | 184 | // Remove sensitive information 185 | if (safeConfig.password) { 186 | safeConfig.password = "********"; 187 | } 188 | 189 | return { 190 | contents: [{ 191 | uri, 192 | mimeType: "application/json", 193 | text: JSON.stringify(safeConfig, null, 2) 194 | }] 195 | }; 196 | } 197 | 198 | // Handle SSH configuration resource 199 | if (uri === "ssh://config") { 200 | const connections = readSSHConnections() as Record; 201 | const safeConnections = { ...connections }; 202 | 203 | // Remove sensitive information from all connections 204 | for (const connection of Object.values(safeConnections)) { 205 | if (connection.password) { 206 | connection.password = "********"; 207 | } 208 | } 209 | 210 | return { 211 | contents: [{ 212 | uri, 213 | mimeType: "application/json", 214 | text: JSON.stringify({ 215 | enabled: this.config.ssh.enabled, 216 | defaultTimeout: this.config.ssh.defaultTimeout, 217 | maxConcurrentSessions: this.config.ssh.maxConcurrentSessions, 218 | connections: safeConnections 219 | }, null, 2) 220 | }] 221 | }; 222 | } 223 | 224 | // Handle current directory resource 225 | if (uri === "cli://currentdir") { 226 | const currentDir = process.cwd(); 227 | return { 228 | contents: [{ 229 | uri, 230 | mimeType: "text/plain", 231 | text: currentDir 232 | }] 233 | }; 234 | } 235 | 236 | // Handle CLI configuration resource 237 | if (uri === "cli://config") { 238 | // Create a safe copy of config (excluding sensitive information) 239 | const safeConfig = { 240 | security: { 241 | ...this.config.security, 242 | }, 243 | shells: { 244 | ...this.config.shells 245 | }, 246 | ssh: { 247 | enabled: this.config.ssh.enabled, 248 | defaultTimeout: this.config.ssh.defaultTimeout, 249 | maxConcurrentSessions: this.config.ssh.maxConcurrentSessions, 250 | connections: Object.keys(this.config.ssh.connections).length 251 | } 252 | }; 253 | 254 | return { 255 | contents: [{ 256 | uri, 257 | mimeType: "application/json", 258 | text: JSON.stringify(safeConfig, null, 2) 259 | }] 260 | }; 261 | } 262 | 263 | throw new McpError( 264 | ErrorCode.InvalidRequest, 265 | `Unknown resource URI: ${uri}` 266 | ); 267 | }); 268 | 269 | // List available tools 270 | this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ 271 | tools: [ 272 | { 273 | name: "execute_command", 274 | description: `Execute a command in the specified shell (powershell, cmd, or gitbash) 275 | 276 | Example usage (PowerShell): 277 | \`\`\`json 278 | { 279 | "shell": "powershell", 280 | "command": "Get-Process | Select-Object -First 5", 281 | "workingDir": "C:\\Users\\username" 282 | } 283 | \`\`\` 284 | 285 | Example usage (CMD): 286 | \`\`\`json 287 | { 288 | "shell": "cmd", 289 | "command": "dir /b", 290 | "workingDir": "C:\\Projects" 291 | } 292 | \`\`\` 293 | 294 | Example usage (Git Bash): 295 | \`\`\`json 296 | { 297 | "shell": "gitbash", 298 | "command": "ls -la", 299 | "workingDir": "/c/Users/username" 300 | } 301 | \`\`\``, 302 | inputSchema: { 303 | type: "object", 304 | properties: { 305 | shell: { 306 | type: "string", 307 | enum: Object.keys(this.config.shells).filter(shell => 308 | this.config.shells[shell as keyof typeof this.config.shells].enabled 309 | ), 310 | description: "Shell to use for command execution" 311 | }, 312 | command: { 313 | type: "string", 314 | description: "Command to execute" 315 | }, 316 | workingDir: { 317 | type: "string", 318 | description: "Working directory for command execution (optional)" 319 | } 320 | }, 321 | required: ["shell", "command"] 322 | } 323 | }, 324 | { 325 | name: "get_command_history", 326 | description: `Get the history of executed commands 327 | 328 | Example usage: 329 | \`\`\`json 330 | { 331 | "limit": 5 332 | } 333 | \`\`\` 334 | 335 | Example response: 336 | \`\`\`json 337 | [ 338 | { 339 | "command": "Get-Process", 340 | "output": "...", 341 | "timestamp": "2024-03-20T10:30:00Z", 342 | "exitCode": 0 343 | } 344 | ] 345 | \`\`\``, 346 | inputSchema: { 347 | type: "object", 348 | properties: { 349 | limit: { 350 | type: "number", 351 | description: `Maximum number of history entries to return (default: 10, max: ${this.config.security.maxHistorySize})` 352 | } 353 | } 354 | } 355 | }, 356 | { 357 | name: "ssh_execute", 358 | description: `Execute a command on a remote host via SSH 359 | 360 | Example usage: 361 | \`\`\`json 362 | { 363 | "connectionId": "raspberry-pi", 364 | "command": "uname -a" 365 | } 366 | \`\`\` 367 | 368 | Configuration required in config.json: 369 | \`\`\`json 370 | { 371 | "ssh": { 372 | "enabled": true, 373 | "connections": { 374 | "raspberry-pi": { 375 | "host": "raspberrypi.local", 376 | "port": 22, 377 | "username": "pi", 378 | "password": "raspberry" 379 | } 380 | } 381 | } 382 | } 383 | \`\`\``, 384 | inputSchema: { 385 | type: "object", 386 | properties: { 387 | connectionId: { 388 | type: "string", 389 | description: "ID of the SSH connection to use", 390 | enum: Object.keys(this.config.ssh.connections) 391 | }, 392 | command: { 393 | type: "string", 394 | description: "Command to execute" 395 | } 396 | }, 397 | required: ["connectionId", "command"] 398 | } 399 | }, 400 | { 401 | name: "ssh_disconnect", 402 | description: `Disconnect from an SSH server 403 | 404 | Example usage: 405 | \`\`\`json 406 | { 407 | "connectionId": "raspberry-pi" 408 | } 409 | \`\`\` 410 | 411 | Use this to cleanly close SSH connections when they're no longer needed.`, 412 | inputSchema: { 413 | type: "object", 414 | properties: { 415 | connectionId: { 416 | type: "string", 417 | description: "ID of the SSH connection to disconnect", 418 | enum: Object.keys(this.config.ssh.connections) 419 | } 420 | }, 421 | required: ["connectionId"] 422 | } 423 | }, 424 | { 425 | name: "create_ssh_connection", 426 | description: "Create a new SSH connection", 427 | inputSchema: { 428 | type: "object", 429 | properties: { 430 | connectionId: { 431 | type: "string", 432 | description: "ID of the SSH connection" 433 | }, 434 | connectionConfig: { 435 | type: "object", 436 | properties: { 437 | host: { 438 | type: "string", 439 | description: "Host of the SSH connection" 440 | }, 441 | port: { 442 | type: "number", 443 | description: "Port of the SSH connection" 444 | }, 445 | username: { 446 | type: "string", 447 | description: "Username for the SSH connection" 448 | }, 449 | password: { 450 | type: "string", 451 | description: "Password for the SSH connection" 452 | }, 453 | privateKeyPath: { 454 | type: "string", 455 | description: "Path to the private key for the SSH connection" 456 | } 457 | }, 458 | required: ["connectionId", "connectionConfig"] 459 | } 460 | } 461 | } 462 | }, 463 | { 464 | name: "read_ssh_connections", 465 | description: "Read all SSH connections", 466 | inputSchema: { 467 | type: "object", 468 | properties: {} // No input parameters needed 469 | } 470 | }, 471 | { 472 | name: "update_ssh_connection", 473 | description: "Update an existing SSH connection", 474 | inputSchema: { 475 | type: "object", 476 | properties: { 477 | connectionId: { 478 | type: "string", 479 | description: "ID of the SSH connection to update" 480 | }, 481 | connectionConfig: { 482 | type: "object", 483 | properties: { 484 | host: { 485 | type: "string", 486 | description: "Host of the SSH connection" 487 | }, 488 | port: { 489 | type: "number", 490 | description: "Port of the SSH connection" 491 | }, 492 | username: { 493 | type: "string", 494 | description: "Username for the SSH connection" 495 | }, 496 | password: { 497 | type: "string", 498 | description: "Password for the SSH connection" 499 | }, 500 | privateKeyPath: { 501 | type: "string", 502 | description: "Path to the private key for the SSH connection" 503 | } 504 | }, 505 | required: ["connectionId", "connectionConfig"] 506 | } 507 | } 508 | } 509 | }, 510 | { 511 | name: "delete_ssh_connection", 512 | description: "Delete an existing SSH connection", 513 | inputSchema: { 514 | type: "object", 515 | properties: { 516 | connectionId: { 517 | type: "string", 518 | description: "ID of the SSH connection to delete" 519 | } 520 | }, 521 | required: ["connectionId"] 522 | } 523 | }, 524 | { 525 | name: "get_current_directory", 526 | description: "Get the current working directory", 527 | inputSchema: { 528 | type: "object", 529 | properties: {} // No input parameters needed 530 | } 531 | } 532 | ] 533 | })); 534 | 535 | // Handle tool execution 536 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => { 537 | try { 538 | switch (request.params.name) { 539 | case "execute_command": { 540 | const args = z.object({ 541 | shell: z.enum(Object.keys(this.config.shells).filter(shell => 542 | this.config.shells[shell as keyof typeof this.config.shells].enabled 543 | ) as [string, ...string[]]), 544 | command: z.string(), 545 | workingDir: z.string().optional() 546 | }).parse(request.params.arguments); 547 | 548 | // Validate command 549 | this.validateCommand(args.shell as keyof ServerConfig['shells'], args.command); 550 | 551 | // Validate working directory if provided 552 | let workingDir = args.workingDir ? 553 | path.resolve(args.workingDir) : 554 | process.cwd(); 555 | 556 | const shellKey = args.shell as keyof typeof this.config.shells; 557 | const shellConfig = this.config.shells[shellKey]; 558 | 559 | if (this.config.security.restrictWorkingDirectory) { 560 | const isAllowedPath = Array.from(this.allowedPaths).some( 561 | allowedPath => workingDir.startsWith(allowedPath) 562 | ); 563 | 564 | if (!isAllowedPath) { 565 | throw new McpError( 566 | ErrorCode.InvalidRequest, 567 | `Working directory (${workingDir}) outside allowed paths. Consult the server admin for configuration changes (config.json - restrictWorkingDirectory, allowedPaths).` 568 | ); 569 | } 570 | } 571 | 572 | // Execute command 573 | return new Promise((resolve, reject) => { 574 | let shellProcess: ReturnType; 575 | 576 | try { 577 | shellProcess = spawn( 578 | shellConfig.command, 579 | [...shellConfig.args, args.command], 580 | { cwd: workingDir, stdio: ['pipe', 'pipe', 'pipe'] } 581 | ); 582 | } catch (err) { 583 | throw new McpError( 584 | ErrorCode.InternalError, 585 | `Failed to start shell process: ${err instanceof Error ? err.message : String(err)}. Consult the server admin for configuration changes (config.json - shells).` 586 | ); 587 | } 588 | 589 | if (!shellProcess.stdout || !shellProcess.stderr) { 590 | throw new McpError( 591 | ErrorCode.InternalError, 592 | 'Failed to initialize shell process streams' 593 | ); 594 | } 595 | 596 | let output = ''; 597 | let error = ''; 598 | 599 | shellProcess.stdout.on('data', (data) => { 600 | output += data.toString(); 601 | }); 602 | 603 | shellProcess.stderr.on('data', (data) => { 604 | error += data.toString(); 605 | }); 606 | 607 | shellProcess.on('close', (code) => { 608 | // Prepare detailed result message 609 | let resultMessage = ''; 610 | 611 | if (code === 0) { 612 | resultMessage = output || 'Command completed successfully (no output)'; 613 | } else { 614 | resultMessage = `Command failed with exit code ${code}\n`; 615 | if (error) { 616 | resultMessage += `Error output:\n${error}\n`; 617 | } 618 | if (output) { 619 | resultMessage += `Standard output:\n${output}`; 620 | } 621 | if (!error && !output) { 622 | resultMessage += 'No error message or output was provided'; 623 | } 624 | } 625 | 626 | // Store in history if enabled 627 | if (this.config.security.logCommands) { 628 | this.commandHistory.push({ 629 | command: args.command, 630 | output: resultMessage, 631 | timestamp: new Date().toISOString(), 632 | exitCode: code ?? -1 633 | }); 634 | 635 | // Trim history if needed 636 | if (this.commandHistory.length > this.config.security.maxHistorySize) { 637 | this.commandHistory = this.commandHistory.slice(-this.config.security.maxHistorySize); 638 | } 639 | } 640 | 641 | resolve({ 642 | content: [{ 643 | type: "text", 644 | text: resultMessage 645 | }], 646 | isError: code !== 0, 647 | metadata: { 648 | exitCode: code ?? -1, 649 | shell: args.shell, 650 | workingDirectory: workingDir 651 | } 652 | }); 653 | }); 654 | 655 | // Handle process errors (e.g., shell crashes) 656 | shellProcess.on('error', (err) => { 657 | const errorMessage = `Shell process error: ${err.message}`; 658 | if (this.config.security.logCommands) { 659 | this.commandHistory.push({ 660 | command: args.command, 661 | output: errorMessage, 662 | timestamp: new Date().toISOString(), 663 | exitCode: -1 664 | }); 665 | } 666 | reject(new McpError( 667 | ErrorCode.InternalError, 668 | errorMessage 669 | )); 670 | }); 671 | 672 | // Set configurable timeout to prevent hanging 673 | const timeout = setTimeout(() => { 674 | shellProcess.kill(); 675 | const timeoutMessage = `Command execution timed out after ${this.config.security.commandTimeout} seconds. Consult the server admin for configuration changes (config.json - commandTimeout).`; 676 | if (this.config.security.logCommands) { 677 | this.commandHistory.push({ 678 | command: args.command, 679 | output: timeoutMessage, 680 | timestamp: new Date().toISOString(), 681 | exitCode: -1 682 | }); 683 | } 684 | reject(new McpError( 685 | ErrorCode.InternalError, 686 | timeoutMessage 687 | )); 688 | }, this.config.security.commandTimeout * 1000); 689 | 690 | shellProcess.on('close', () => clearTimeout(timeout)); 691 | }); 692 | } 693 | 694 | case "get_command_history": { 695 | if (!this.config.security.logCommands) { 696 | return { 697 | content: [{ 698 | type: "text", 699 | text: "Command history is disabled in configuration. Consult the server admin for configuration changes (config.json - logCommands)." 700 | }] 701 | }; 702 | } 703 | 704 | const args = z.object({ 705 | limit: z.number() 706 | .min(1) 707 | .max(this.config.security.maxHistorySize) 708 | .optional() 709 | .default(10) 710 | }).parse(request.params.arguments); 711 | 712 | const history = this.commandHistory 713 | .slice(-args.limit) 714 | .map(entry => ({ 715 | ...entry, 716 | output: entry.output.slice(0, 1000) // Limit output size 717 | })); 718 | 719 | return { 720 | content: [{ 721 | type: "text", 722 | text: JSON.stringify(history, null, 2) 723 | }] 724 | }; 725 | } 726 | 727 | case "ssh_execute": { 728 | if (!this.config.ssh.enabled) { 729 | throw new McpError( 730 | ErrorCode.InvalidRequest, 731 | "SSH support is disabled in configuration" 732 | ); 733 | } 734 | 735 | const args = z.object({ 736 | connectionId: z.string(), 737 | command: z.string() 738 | }).parse(request.params.arguments); 739 | 740 | const connectionConfig = this.config.ssh.connections[args.connectionId]; 741 | if (!connectionConfig) { 742 | throw new McpError( 743 | ErrorCode.InvalidRequest, 744 | `Unknown SSH connection ID: ${args.connectionId}` 745 | ); 746 | } 747 | 748 | try { 749 | // Validate command 750 | this.validateCommand('cmd', args.command); 751 | 752 | const connection = await this.sshPool.getConnection(args.connectionId, connectionConfig); 753 | const { output, exitCode } = await connection.executeCommand(args.command); 754 | 755 | // Store in history if enabled 756 | if (this.config.security.logCommands) { 757 | this.commandHistory.push({ 758 | command: args.command, 759 | output, 760 | timestamp: new Date().toISOString(), 761 | exitCode, 762 | connectionId: args.connectionId 763 | }); 764 | 765 | if (this.commandHistory.length > this.config.security.maxHistorySize) { 766 | this.commandHistory = this.commandHistory.slice(-this.config.security.maxHistorySize); 767 | } 768 | } 769 | 770 | return { 771 | content: [{ 772 | type: "text", 773 | text: output || 'Command completed successfully (no output)' 774 | }], 775 | isError: exitCode !== 0, 776 | metadata: { 777 | exitCode, 778 | connectionId: args.connectionId 779 | } 780 | }; 781 | } catch (error) { 782 | const errorMessage = error instanceof Error ? error.message : String(error); 783 | if (this.config.security.logCommands) { 784 | this.commandHistory.push({ 785 | command: args.command, 786 | output: `SSH error: ${errorMessage}`, 787 | timestamp: new Date().toISOString(), 788 | exitCode: -1, 789 | connectionId: args.connectionId 790 | }); 791 | } 792 | throw new McpError( 793 | ErrorCode.InternalError, 794 | `SSH error: ${errorMessage}` 795 | ); 796 | } 797 | } 798 | 799 | case "ssh_disconnect": { 800 | if (!this.config.ssh.enabled) { 801 | throw new McpError( 802 | ErrorCode.InvalidRequest, 803 | "SSH support is disabled in configuration" 804 | ); 805 | } 806 | 807 | const args = z.object({ 808 | connectionId: z.string() 809 | }).parse(request.params.arguments); 810 | 811 | await this.sshPool.closeConnection(args.connectionId); 812 | return { 813 | content: [{ 814 | type: "text", 815 | text: `Disconnected from ${args.connectionId}` 816 | }] 817 | }; 818 | } 819 | 820 | case 'create_ssh_connection': { 821 | const args = z.object({ 822 | connectionId: z.string(), 823 | connectionConfig: z.object({ 824 | host: z.string(), 825 | port: z.number(), 826 | username: z.string(), 827 | password: z.string().optional(), 828 | privateKeyPath: z.string().optional(), 829 | }) 830 | }).parse(request.params.arguments); 831 | createSSHConnection(args.connectionId, args.connectionConfig); 832 | return { content: [{ type: 'text', text: 'SSH connection created successfully.' }] }; 833 | } 834 | 835 | case 'read_ssh_connections': { 836 | const connections = readSSHConnections(); 837 | return { content: [{ type: 'json', text: JSON.stringify(connections, null, 2) }] }; 838 | } 839 | 840 | case 'update_ssh_connection': { 841 | const args = z.object({ 842 | connectionId: z.string(), 843 | connectionConfig: z.object({ 844 | host: z.string(), 845 | port: z.number(), 846 | username: z.string(), 847 | password: z.string().optional(), 848 | privateKeyPath: z.string().optional(), 849 | }) 850 | }).parse(request.params.arguments); 851 | updateSSHConnection(args.connectionId, args.connectionConfig); 852 | return { content: [{ type: 'text', text: 'SSH connection updated successfully.' }] }; 853 | } 854 | 855 | case 'delete_ssh_connection': { 856 | const args = z.object({ 857 | connectionId: z.string(), 858 | }).parse(request.params.arguments); 859 | deleteSSHConnection(args.connectionId); 860 | return { content: [{ type: 'text', text: 'SSH connection deleted successfully.' }] }; 861 | } 862 | 863 | case 'get_current_directory': { 864 | const currentDir = process.cwd(); 865 | return { content: [{ type: 'text', text: `Current working directory: ${currentDir}` }] }; 866 | } 867 | 868 | default: 869 | throw new McpError( 870 | ErrorCode.MethodNotFound, 871 | `Unknown tool: ${request.params.name}` 872 | ); 873 | } 874 | } catch (error) { 875 | if (error instanceof z.ZodError) { 876 | throw new McpError( 877 | ErrorCode.InvalidParams, 878 | `Invalid arguments: ${error.errors.map(e => e.message).join(', ')}` 879 | ); 880 | } 881 | throw error; 882 | } 883 | }); 884 | } 885 | 886 | private async cleanup(): Promise { 887 | this.sshPool.closeAll(); 888 | } 889 | 890 | async run(): Promise { 891 | const transport = new StdioServerTransport(); 892 | 893 | // Set up cleanup handler 894 | process.on('SIGINT', async () => { 895 | await this.cleanup(); 896 | process.exit(0); 897 | }); 898 | 899 | await this.server.connect(transport); 900 | console.error("Windows CLI MCP Server running on stdio"); 901 | } 902 | } 903 | 904 | // Start server 905 | const main = async () => { 906 | try { 907 | const args = await parseArgs(); 908 | 909 | // Handle --init-config flag 910 | if (args['init-config']) { 911 | try { 912 | createDefaultConfig(args['init-config'] as string); 913 | console.error(`Created default config at: ${args['init-config']}`); 914 | process.exit(0); 915 | } catch (error) { 916 | console.error('Failed to create config file:', error); 917 | process.exit(1); 918 | } 919 | } 920 | 921 | // Load configuration 922 | const config = loadConfig(args.config); 923 | 924 | const server = new CLIServer(config); 925 | await server.run(); 926 | } catch (error) { 927 | console.error("Fatal error:", error); 928 | process.exit(1); 929 | } 930 | }; 931 | 932 | main(); --------------------------------------------------------------------------------