UNPKG

otak-mcp-shell

Version:

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

677 lines 31.2 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 express_1 = __importDefault(require("express")); const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js"); const sse_js_1 = require("@modelcontextprotocol/sdk/server/sse.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 dotenv_1 = __importDefault(require("dotenv")); dotenv_1.default.config(); // デフォルトディレクトリ const DEFAULT_DIR = path_1.default.join(os_1.default.homedir(), 'Desktop', 'Otak'); // 許可されたディレクトリ(環境変数またはデフォルト) let allowedDirectory = process.env.ALLOWED_DIRECTORY ? path_1.default.resolve(expandTilde(process.env.ALLOWED_DIRECTORY)) : DEFAULT_DIR; // チルダ展開を処理する関数 function expandTilde(filepath) { if (filepath.startsWith('~/') || filepath === '~') { return filepath.replace('~', os_1.default.homedir()); } return filepath; } // 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 isCommandSafe(command) { const lowerCommand = command.toLowerCase().trim(); // 削除、移動、リネーム、権限変更系のコマンドをチェック const destructivePatterns = [ // 基本的な削除コマンド /(?:remove-item|rm|del|erase|rmdir|rd)\s+.*[c-z]:\\/i, // ドライブルートの削除 /(?:remove-item|rm|del|erase|rmdir|rd)\s+.*windows/i, // Windowsディレクトリ /(?:remove-item|rm|del|erase|rmdir|rd)\s+.*program\s*files/i, // Program Files /(?:remove-item|rm|del|erase|rmdir|rd)\s+.*system32/i, // System32 /(?:remove-item|rm|del|erase|rmdir|rd)\s+.*syswow64/i, // SysWOW64 // 移動・リネーム /(?: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 /(?:move-item|mv|move|ren|rename)\s+.*system32/i, // System32 // 再帰的コピー(潜在的に危険) /copy-item\s+.*-recurse.*[c-z]:\\/i, // ドライブルートへの再帰コピー /copy-item\s+.*-recurse.*windows/i, // Windowsディレクトリへの再帰コピー /copy-item\s+.*-recurse.*program\s*files/i, // Program Filesへの再帰コピー // 権限変更 /icacls\s+.*[c-z]:\\/i, // ドライブルートの権限変更 /icacls\s+.*windows/i, // Windowsディレクトリの権限変更 /icacls\s+.*program\s*files/i, // Program Filesの権限変更 /icacls\s+.*system32/i, // System32の権限変更 // ワイルドカードを含む破壊的操作 /(?:remove-item|rm|del|erase|rmdir|rd)\s+.*\*.*[c-z]:\\/i, // ワイルドカード削除 /(?:remove-item|rm|del|erase|rmdir|rd)\s+.*\*.*windows/i, // ワイルドカード削除 /(?:remove-item|rm|del|erase|rmdir|rd)\s+.*\*.*program\s*files/i, // ワイルドカード削除 /(?:remove-item|rm|del|erase|rmdir|rd)\s+.*\*.*system32/i, // ワイルドカード削除 // システム関連の危険なコマンド /format\s+[c-z]:/i, // ドライブのフォーマット /sdelete/i, // セキュア削除 /cipher\s+.*\/w/i, // ワイプ /shutdown/i, // システム終了 /restart-computer/i, // 再起動 /stop-computer/i, // システム停止 // レジストリ操作 /reg\s+delete/i, // レジストリ削除 /remove-itemproperty.*hklm/i, // HKLM レジストリ削除 /remove-itemproperty.*hkcu/i, // HKCU レジストリ削除 // ネットワーク関連の危険な操作 /netsh.*delete/i, // ネットワーク設定削除 /route\s+delete/i, // ルート削除 ]; for (const pattern of destructivePatterns) { if (pattern.test(command)) { return false; } } // 基本的には全てのコマンドを許可(保護されたパスへの操作以外) return true; } // USモードでコマンドを実行するための関数 function toUSMode(cmd) { const usPrefix = '[cultureinfo]::CurrentCulture="en-US";' + '[cultureinfo]::CurrentUICulture="en-US";' + 'chcp 437;'; return `${usPrefix} ${cmd}`; } // コマンドを実行する関数(Windows専用) function executeCommand(command, workingDir) { return new Promise((resolve) => { const startTime = Date.now(); // PowerShell 5.0互換性のためのコマンド変換 let processedCommand = command .replace(/&&/g, ';') // &&を;に置換 .trim(); // USモードでコマンドを実行 processedCommand = toUSMode(processedCommand); // Windows専用 - PowerShellを使用(セキュアオプション) const shell = 'powershell.exe'; const shellArgs = [ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', processedCommand ]; 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 }); }); 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() { try { await promises_1.default.mkdir(allowedDirectory, { recursive: true }); console.log(`Working directory: ${allowedDirectory}`); } catch (error) { console.error('Failed to create directory:', error); } } // MCPサーバーのセットアップ const server = new index_js_1.Server({ name: 'otak-mcp-shell', version: '2.0.3', }, { capabilities: { tools: {}, logging: {}, }, }); // サーバーの初期化イベントハンドラー server.onerror = (error) => { console.error('MCP Server error:', error); }; server.onclose = () => { console.log('MCP Server connection closed'); }; server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => { return { tools: [ { name: 'Execute', description: 'Execute a PowerShell command in the allowed directory with security restrictions. PowerShell 5.0互換のため&&禁止(自動で;置換)、シェル確認挿入、対話型CLIは--yes/-y等で非対話ワンライナー化し複数工程も1行連結、無効構文には注意コメントを添えること', inputSchema: { type: 'object', properties: { command: { type: 'string', description: 'The PowerShell command to execute. Use semicolon (;) instead of && for command chaining. Interactive commands should use -y or --yes flags.', }, 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 safe PowerShell commands that can be executed', inputSchema: { type: 'object', properties: { category: { type: 'string', description: 'Filter commands by category (file, text, system, network, dev, powershell)', }, }, required: [], }, }, { name: 'PWD', description: 'Get the current working directory path', 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 (!workingDir.startsWith(allowedDirectory)) { throw new Error('Working directory outside allowed directory'); } if (!command) { throw new Error('Command is required'); } if (!isCommandSafe(command)) { throw new Error(`Command not allowed for security reasons: ${command}`); } 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-ChildItem -Force', 'Get-Location', 'Set-Location dirname', 'New-Item -ItemType Directory dirname', 'Remove-Item dirname', 'New-Item 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', 'Test-Path filename', 'Resolve-Path filename' ], text: [ 'Select-String pattern filename', 'Get-Content filename | ForEach-Object { $_ -replace "old","new" }', 'Get-Content filename | ForEach-Object { ($_ -split " ")[0] }', 'Get-Content filename | Sort-Object', 'Get-Content filename | Sort-Object | Get-Unique', 'Get-Content filename | Measure-Object -Line -Word -Character', 'Write-Output "text"', 'Get-ChildItem -Recurse -Filter "pattern"', 'Compare-Object (Get-Content file1) (Get-Content file2)' ], system: [ '$env:USERNAME', 'Get-Date', 'Get-ComputerInfo', 'Get-Process', 'Get-Process | Sort-Object CPU -Descending | Select-Object -First 10', 'Get-WmiObject -Class Win32_LogicalDisk', 'Get-ChildItem | Measure-Object -Property Length -Sum', 'Get-WmiObject -Class Win32_OperatingSystem | Select-Object LastBootUpTime', 'Get-Command commandname', 'Get-Service', 'Get-EventLog -LogName System -Newest 10' ], network: [ 'Test-NetConnection hostname', 'Invoke-WebRequest url', 'Invoke-WebRequest url -OutFile filename', 'Resolve-DnsName hostname', 'Test-NetConnection hostname -Port 80', 'Get-NetAdapter', 'Get-NetIPAddress', 'Get-NetRoute' ], dev: [ 'git status', 'git log --oneline', 'npm list', 'node --version', 'python --version', 'dotnet --version', 'Get-Module -ListAvailable', 'Get-InstalledModule', 'Get-PackageProvider', 'Get-Package' ], powershell: [ 'Get-Help commandname', 'Get-Command *keyword*', 'Get-Member', 'Get-History', 'Get-Alias', 'Get-Variable', 'Get-ExecutionPolicy', 'Get-PSVersion', 'Get-Module', 'Import-Module modulename', 'Export-ModuleMember', 'Get-Credential' ] }; const result = category && commands[category] ? { [category]: commands[category] } : commands; return { content: [ { type: 'text', text: JSON.stringify({ description: 'Common safe PowerShell commands by category for Windows 11 PowerShell 5.0', note: 'These are PowerShell cmdlets and commands optimized for Windows environments. Dangerous operations are blocked.', commands: result }, null, 2), }, ], }; } case 'PWD': { return { content: [ { type: 'text', text: allowedDirectory.replace(/\\/g, '/'), }, ], }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`MCP tool error (${name}):`, errorMessage); return { content: [ { type: 'text', text: `Error executing ${name}: ${errorMessage}`, }, ], isError: true, }; } }); // Expressアプリケーションのセットアップ const app = (0, express_1.default)(); app.use(express_1.default.json({ limit: '10mb' })); // CORS設定 app.use((req, res, next) => { // すべてのオリジンを許可 res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE, PATCH'); res.header('Access-Control-Allow-Headers', '*'); res.header('Access-Control-Max-Age', '86400'); // OPTIONSリクエストに対して即座に200を返す if (req.method === 'OPTIONS') { return res.status(200).end(); } next(); }); // ヘルスチェック app.get('/health', (req, res) => { res.json({ status: 'healthy', service: 'otak-mcp-shell-mcp', workingDirectory: allowedDirectory.replace(/\\/g, '/'), timestamp: new Date().toISOString() }); }); // MCPエンドポイント - GET (情報表示) app.get('/mcp', (req, res) => { res.json({ message: 'Shell MCP Server is running', version: '1.0.0', endpoints: { post: '/mcp - JSON-RPC requests', sse: '/sse - Server-Sent Events stream', health: '/health - Health check' } }); }); // MCPエンドポイント - HTTP POST app.post('/mcp', async (req, res) => { console.log('Received MCP request:', req.body); // シンプルなリクエスト/レスポンス処理 const request = req.body; // Notificationの場合はレスポンスを返さない if (!request.id && request.method === 'notifications/initialized') { console.log('Received initialized notification'); res.status(200).end(); return; } try { if (request.method === 'initialize') { res.json({ jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {}, }, serverInfo: { name: 'otak-mcp-shell', version: '2.0.3', }, }, }); } else if (request.method === 'tools/list') { // 直接ツール一覧を返す res.json({ jsonrpc: '2.0', id: request.id, result: { tools: [ { name: 'Execute', description: 'Execute a PowerShell command in the allowed directory with security restrictions. PowerShell 5.0互換のため&&禁止(自動で;置換)、シェル確認挿入、対話型CLIは--yes/-y等で非対話ワンライナー化し複数工程も1行連結、無効構文には注意コメントを添えること', inputSchema: { type: 'object', properties: { command: { type: 'string', description: 'The PowerShell command to execute. Use semicolon (;) instead of && for command chaining. Interactive commands should use -y or --yes flags.', }, 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 safe PowerShell commands that can be executed', inputSchema: { type: 'object', properties: { category: { type: 'string', description: 'Filter commands by category (file, text, system, network, dev, powershell)', }, }, required: [], }, }, { name: 'PWD', description: 'Get the current working directory path', inputSchema: { type: 'object', properties: {}, required: [], }, }, ], }, }); } else if (request.method === 'tools/call') { // ツール実行のロジック const { name, arguments: args } = request.params; try { let result; switch (name) { case 'Execute': { const command = args?.command; // パストラバーサル対策強化 const workingDir = args?.workingDir ? path_1.default.resolve(allowedDirectory, args.workingDir) : allowedDirectory; // 解決されたパスが許可されたディレクトリ内にあることを確認 if (!workingDir.startsWith(allowedDirectory)) { throw new Error('Working directory outside allowed directory'); } if (!command) { throw new Error('Command is required'); } if (!isCommandSafe(command)) { throw new Error(`Command not allowed for security reasons: ${command}`); } const execResult = await executeCommand(command, workingDir); const response = { command: execResult.command, workingDirectory: workingDir.replace(/\\/g, '/'), exitCode: execResult.exitCode, duration: `${execResult.duration}ms`, stdout: execResult.stdout || '(no output)', stderr: execResult.stderr || '(no errors)', success: execResult.exitCode === 0 }; result = { content: [ { type: 'text', text: JSON.stringify(response, null, 2), }, ], }; break; } case 'ListCommands': { const category = args?.category; const commands = { file: [ 'Get-ChildItem', 'Get-ChildItem -Force', 'Get-Location', 'Set-Location dirname', 'New-Item -ItemType Directory dirname', 'Remove-Item dirname', 'New-Item 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', 'Test-Path filename', 'Resolve-Path filename' ], text: [ 'Select-String pattern filename', 'Get-Content filename | ForEach-Object { $_ -replace "old","new" }', 'Get-Content filename | ForEach-Object { ($_ -split " ")[0] }', 'Get-Content filename | Sort-Object', 'Get-Content filename | Sort-Object | Get-Unique', 'Get-Content filename | Measure-Object -Line -Word -Character', 'Write-Output "text"', 'Get-ChildItem -Recurse -Filter "pattern"', 'Compare-Object (Get-Content file1) (Get-Content file2)' ], system: [ '$env:USERNAME', 'Get-Date', 'Get-ComputerInfo', 'Get-Process', 'Get-Process | Sort-Object CPU -Descending | Select-Object -First 10', 'Get-WmiObject -Class Win32_LogicalDisk', 'Get-ChildItem | Measure-Object -Property Length -Sum', 'Get-WmiObject -Class Win32_OperatingSystem | Select-Object LastBootUpTime', 'Get-Command commandname', 'Get-Service', 'Get-EventLog -LogName System -Newest 10' ], network: [ 'Test-NetConnection hostname', 'Invoke-WebRequest url', 'Invoke-WebRequest url -OutFile filename', 'Resolve-DnsName hostname', 'Test-NetConnection hostname -Port 80', 'Get-NetAdapter', 'Get-NetIPAddress', 'Get-NetRoute' ], dev: [ 'git status', 'git log --oneline', 'npm list', 'node --version', 'python --version', 'dotnet --version', 'Get-Module -ListAvailable', 'Get-InstalledModule', 'Get-PackageProvider', 'Get-Package' ], powershell: [ 'Get-Help commandname', 'Get-Command *keyword*', 'Get-Member', 'Get-History', 'Get-Alias', 'Get-Variable', 'Get-ExecutionPolicy', 'Get-PSVersion', 'Get-Module', 'Import-Module modulename', 'Export-ModuleMember', 'Get-Credential' ] }; const cmdResult = category && commands[category] ? { [category]: commands[category] } : commands; result = { content: [ { type: 'text', text: JSON.stringify({ description: 'Common safe PowerShell commands by category for Windows 11 PowerShell 5.0', note: 'These are PowerShell cmdlets and commands optimized for Windows environments. Dangerous operations are blocked.', commands: cmdResult }, null, 2), }, ], }; break; } case 'PWD': { result = { content: [ { type: 'text', text: allowedDirectory.replace(/\\/g, '/'), }, ], }; break; } default: throw new Error(`Unknown tool: ${name}`); } res.json({ jsonrpc: '2.0', id: request.id, result, }); } catch (error) { res.json({ jsonrpc: '2.0', id: request.id, result: { content: [ { type: 'text', text: `Error executing ${name}: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }, }); } } else { res.json({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: `Method not found: ${request.method}`, }, }); } } catch (error) { console.error('MCP request error:', error); res.json({ jsonrpc: '2.0', id: request.id, error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error', }, }); } }); // MCPエンドポイント - SSE接続 app.get('/sse', async (req, res) => { const transport = new sse_js_1.SSEServerTransport('/message', res); await server.connect(transport); }); // サーバー起動 const PORT = parseInt(process.env.PORT || '8767', 10); const HOST = process.env.HOST || 'localhost'; // 初期化してからサーバーを起動 initialize().then(() => { app.listen(PORT, HOST, () => { console.log(`Shell MCP over HTTP/SSE server running on ${HOST}:${PORT}`); console.log(`MCP HTTP endpoint: http://${HOST}:${PORT}/mcp`); console.log(`MCP SSE endpoint: http://${HOST}:${PORT}/sse`); console.log(`Health check: http://${HOST}:${PORT}/health`); console.log(`Allowed directory: ${allowedDirectory}`); }).on('error', (err) => { if (err.code === 'EADDRINUSE') { console.error(`Port ${PORT} is already in use. Please specify a different port using the PORT environment variable.`); } else { console.error('Server error:', err); } process.exit(1); }); }).catch((error) => { console.error('Initialization error:', error); process.exit(1); }); //# sourceMappingURL=mcp-http-server.js.map