index.js•22.6 kB
#!/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 { 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 {
constructor(config) {
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();
}
validateCommand(shell, command) {
// 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
*/
escapeRegex(text) {
return text.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}
setupHandlers() {
// 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].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].enabled)),
command: z.string(),
workingDir: z.string().optional()
}).parse(request.params.arguments);
// Validate command
this.validateCommand(args.shell, args.command);
// Validate working directory if provided
let workingDir = args.workingDir ?
path.resolve(args.workingDir) :
process.cwd();
const shellKey = args.shell;
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;
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;
}
});
}
async cleanup() {
this.sshPool.closeAll();
}
async run() {
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']);
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();