UNPKG

otak-mcp-shell

Version:

Windows PowerShell MCP server with system directory protection and SSE/HTTP streaming support

409 lines 16.5 kB
#!/usr/bin/env node "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js"); const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js"); const types_js_1 = require("@modelcontextprotocol/sdk/types.js"); const child_process_1 = require("child_process"); const promises_1 = __importDefault(require("fs/promises")); const path_1 = __importDefault(require("path")); const os_1 = __importDefault(require("os")); // デフォルトディレクトリ const DEFAULT_DIR = path_1.default.join(os_1.default.homedir(), 'Desktop', 'Otak'); // 許可されたディレクトリ let allowedDirectory = DEFAULT_DIR; // Windows専用 - 保護されたディレクトリ const PROTECTED_DIRECTORIES = [ 'C:\\', 'C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)', 'C:\\Users', '~', '~/Desktop', process.env.USERPROFILE || '', process.env.USERPROFILE ? path_1.default.join(process.env.USERPROFILE, 'Desktop') : '', process.env.SYSTEMROOT || 'C:\\Windows', process.env.PROGRAMFILES || 'C:\\Program Files', process.env.PROGRAMFILES_X86 || 'C:\\Program Files (x86)' ].filter(dir => dir); // 空文字列を除外 // チルダ展開を処理する関数 function expandTilde(filepath) { if (filepath.startsWith('~/') || filepath === '~') { return filepath.replace('~', os_1.default.homedir()); } return filepath; } // パスが許可されたディレクトリ内にあるかチェック function isPathAllowed(targetPath) { const resolvedPath = path_1.default.resolve(targetPath); const resolvedAllowed = path_1.default.resolve(allowedDirectory); return resolvedPath.startsWith(resolvedAllowed); } // 保護されたディレクトリへの操作をチェック function isProtectedPath(targetPath) { const normalizedPath = path_1.default.resolve(targetPath).toLowerCase(); for (const protectedDir of PROTECTED_DIRECTORIES) { const normalizedProtected = path_1.default.resolve(expandTilde(protectedDir)).toLowerCase(); if (normalizedPath === normalizedProtected || normalizedPath.startsWith(normalizedProtected + path_1.default.sep)) { return true; } } return false; } // コマンドが保護されたディレクトリに影響しないかチェック function isCommandSafe(command) { const lowerCommand = command.toLowerCase().trim(); // 削除、移動、リネーム系のコマンドをチェック const destructivePatterns = [ /(?:remove-item|rm|del|erase)\s+.*[c-z]:\\/i, // ドライブルートの削除 /(?:remove-item|rm|del|erase)\s+.*windows/i, // Windowsディレクトリ /(?:remove-item|rm|del|erase)\s+.*program\s*files/i, // Program Files /(?:move-item|mv|move|ren|rename)\s+.*[c-z]:\\/i, // ドライブルートの移動 /(?:move-item|mv|move|ren|rename)\s+.*windows/i, // Windowsディレクトリ /(?:move-item|mv|move|ren|rename)\s+.*program\s*files/i, // Program Files ]; for (const pattern of destructivePatterns) { if (pattern.test(command)) { return false; } } // 基本的には全てのコマンドを許可(保護されたパスへの操作以外) return true; } // コマンドを実行する関数(Windows専用) function executeCommand(command, workingDir) { return new Promise((resolve) => { const startTime = Date.now(); // Windows専用 - PowerShellを使用 const shell = 'powershell.exe'; const shellArgs = ['-Command', command]; const child = (0, child_process_1.spawn)(shell, shellArgs, { cwd: workingDir, stdio: ['pipe', 'pipe', 'pipe'], env: process.env }); let stdout = ''; let stderr = ''; child.stdout?.on('data', (data) => { stdout += data.toString(); }); child.stderr?.on('data', (data) => { stderr += data.toString(); }); child.on('close', (code) => { const duration = Date.now() - startTime; resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code || 0, command, duration }); }); child.on('error', (error) => { const duration = Date.now() - startTime; resolve({ stdout: '', stderr: error.message, exitCode: 1, command, duration }); }); // 30秒でタイムアウト setTimeout(() => { child.kill('SIGTERM'); const duration = Date.now() - startTime; resolve({ stdout: stdout.trim(), stderr: 'Command timed out after 30 seconds', exitCode: 124, command, duration }); }, 30000); }); } // 初期化処理 async function initialize() { // コマンドライン引数から設定を取得 const args = process.argv.slice(2); if (args.length > 0) { try { const config = JSON.parse(args[0]); if (config.allowedDirectory) { allowedDirectory = path_1.default.resolve(expandTilde(config.allowedDirectory)); } } catch (error) { console.error('Invalid configuration:', error); } } // デフォルトディレクトリが存在しない場合は作成 try { await promises_1.default.mkdir(allowedDirectory, { recursive: true }); console.error(`Working directory: ${allowedDirectory}`); } catch (error) { console.error('Failed to create directory:', error); } } const server = new index_js_1.Server({ name: 'windows-shell-mcp-server', version: '1.0.0', }, { capabilities: { tools: {}, }, }); server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => { return { tools: [ { name: 'Execute', description: 'Execute a PowerShell command in the allowed directory with Windows system protection', inputSchema: { type: 'object', properties: { command: { type: 'string', description: 'The shell command to execute', }, workingDir: { type: 'string', description: 'The working directory for command execution (optional, defaults to allowed directory)', }, }, required: ['command'], }, }, { name: 'ListCommands', description: 'Get a list of common PowerShell commands that can be executed', inputSchema: { type: 'object', properties: { category: { type: 'string', description: 'Filter commands by category (file, text, system, network, dev)', }, }, required: [], }, }, { name: 'PWD', description: 'Get the current working directory path', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'CD', description: 'Change the current working directory (updates the allowed directory)', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'The directory path to change to', }, }, required: ['path'], }, }, { name: 'WhichShell', description: 'Get information about the current PowerShell and Windows platform', inputSchema: { type: 'object', properties: {}, required: [], }, }, ], }; }); server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case 'Execute': { const command = args?.command; const workingDir = args?.workingDir ? path_1.default.resolve(allowedDirectory, args.workingDir) : allowedDirectory; if (!command) { throw new Error('Command is required'); } // セキュリティチェック if (!isCommandSafe(command)) { throw new Error(`Command blocked - attempting to access protected Windows directories: ${command}`); } // 作業ディレクトリのチェック if (!isPathAllowed(workingDir)) { throw new Error(`Working directory outside allowed area: ${workingDir}`); } // コマンド実行 const result = await executeCommand(command, workingDir); const response = { command: result.command, workingDirectory: workingDir.replace(/\\/g, '/'), exitCode: result.exitCode, duration: `${result.duration}ms`, stdout: result.stdout || '(no output)', stderr: result.stderr || '(no errors)', success: result.exitCode === 0 }; return { content: [ { type: 'text', text: JSON.stringify(response, null, 2), }, ], }; } case 'ListCommands': { const category = args?.category; const commands = { file: [ 'Get-ChildItem', 'Get-Location', 'Set-Location C:\\path', 'New-Item -ItemType Directory -Name dirname', 'Remove-Item dirname', 'New-Item -ItemType File -Name filename', 'Get-Content filename', 'Get-Content filename -Head 10', 'Get-Content filename -Tail 10', 'Copy-Item source dest', 'Move-Item source dest', 'Remove-Item filename' ], text: [ 'Select-String "pattern" filename', 'Get-Content file | ForEach-Object { $_ -replace "old", "new" }', 'Get-Content file | ForEach-Object { $_.Split()[0] }', 'Get-Content file | Sort-Object', 'Get-Content file | Select-Object -Unique', 'Get-Content file | Measure-Object -Line', 'Write-Output "text"', 'Get-ChildItem -Recurse -Name "*pattern*"' ], system: [ 'whoami', 'Get-Date', 'Get-ComputerInfo', 'Get-Process', 'Get-Process | Sort-Object CPU -Descending', 'Get-Volume', 'Get-ChildItem -Recurse | Measure-Object -Property Length -Sum' ], network: [ 'Test-Connection hostname', 'Invoke-WebRequest url', 'Resolve-DnsName hostname', 'nslookup hostname' ], dev: [ 'git status', 'git log --oneline', 'npm list', 'node --version', 'python --version', 'java -version', 'Get-Command git' ] }; const result = category && commands[category] ? { [category]: commands[category] } : commands; return { content: [ { type: 'text', text: JSON.stringify({ description: 'Common safe commands by category', note: 'These are examples of allowed commands. Dangerous operations are blocked.', commands: result }, null, 2), }, ], }; } case 'PWD': { return { content: [ { type: 'text', text: allowedDirectory.replace(/\\/g, '/'), }, ], }; } case 'CD': { const targetPath = args?.path; if (!targetPath) { throw new Error('Path is required'); } const newPath = path_1.default.resolve(allowedDirectory, targetPath); // パスが許可されたディレクトリ内にあるかチェック if (!isPathAllowed(newPath)) { throw new Error(`Path outside allowed directory: ${newPath}`); } // ディレクトリが存在するかチェック try { const stats = await promises_1.default.stat(newPath); if (!stats.isDirectory()) { throw new Error(`Not a directory: ${newPath}`); } } catch (error) { throw new Error(`Directory does not exist: ${newPath}`); } // 許可されたディレクトリを更新 allowedDirectory = newPath; return { content: [ { type: 'text', text: `Changed working directory to: ${allowedDirectory.replace(/\\/g, '/')}`, }, ], }; } case 'WhichShell': { const isWindows = process.platform === 'win32'; const shell = isWindows ? 'cmd.exe' : '/bin/bash'; const info = { platform: process.platform, architecture: process.arch, nodeVersion: process.version, shell: shell, isWindows: isWindows, workingDirectory: allowedDirectory.replace(/\\/g, '/'), environment: { HOME: process.env.HOME || process.env.USERPROFILE, PATH: process.env.PATH?.split(path_1.default.delimiter).slice(0, 5) // First 5 PATH entries } }; return { content: [ { type: 'text', text: JSON.stringify(info, null, 2), }, ], }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { return { content: [ { type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); async function main() { await initialize(); const transport = new stdio_js_1.StdioServerTransport(); await server.connect(transport); console.error('Shell MCP server running on stdio'); } main().catch((error) => { console.error('Server error:', error); process.exit(1); }); //# sourceMappingURL=index.js.map