UNPKG

ai-cli-mcp

Version:

MCP server for AI CLI tools (Claude, Codex, and Gemini) with background process management

772 lines (642 loc) 23.5 kB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { EventEmitter } from 'node:events'; // Mock dependencies vi.mock('node:child_process'); vi.mock('node:fs'); vi.mock('node:os'); vi.mock('@modelcontextprotocol/sdk/server/stdio.js'); vi.mock('@modelcontextprotocol/sdk/types.js', () => ({ ListToolsRequestSchema: { name: 'listTools' }, CallToolRequestSchema: { name: 'callTool' }, ErrorCode: { InternalError: 'InternalError', MethodNotFound: 'MethodNotFound', InvalidParams: 'InvalidParams' }, McpError: class McpError extends Error { code: string; constructor(code: string, message: string) { super(message); this.code = code; this.name = 'McpError'; } } })); vi.mock('@modelcontextprotocol/sdk/server/index.js', () => ({ Server: vi.fn().mockImplementation(function() { return { setRequestHandler: vi.fn(), connect: vi.fn(), close: vi.fn(), onerror: undefined, }; }), })); const mockExistsSync = vi.mocked(existsSync); const mockSpawn = vi.mocked(spawn); const mockHomedir = vi.mocked(homedir); describe('Process Management Tests', () => { let consoleErrorSpy: any; let originalEnv: any; let mockServerInstance: any; let handlers: Map<string, Function>; beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); originalEnv = { ...process.env }; process.env = { ...originalEnv }; handlers = new Map(); // Set up default mocks mockHomedir.mockReturnValue('/home/user'); mockExistsSync.mockReturnValue(true); }); afterEach(() => { consoleErrorSpy.mockRestore(); process.env = originalEnv; }); async function setupServer() { const { Server } = await import('@modelcontextprotocol/sdk/server/index.js'); vi.mocked(Server).mockImplementation(() => { mockServerInstance = { setRequestHandler: vi.fn((schema: any, handler: Function) => { handlers.set(schema.name, handler); }), connect: vi.fn(), close: vi.fn(), onerror: undefined }; return mockServerInstance as any; }); const module = await import('../server.js'); const { ClaudeCodeServer } = module; const server = new ClaudeCodeServer(); return { server, module, handlers }; } describe('claude_code tool with PID return', () => { it('should return PID immediately when starting a process', async () => { const { handlers } = await setupServer(); // Create a mock process const mockProcess = new EventEmitter() as any; mockProcess.pid = 12345; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockSpawn.mockReturnValue(mockProcess); const callToolHandler = handlers.get('callTool')!; const result = await callToolHandler!({ params: { name: 'claude_code', arguments: { prompt: 'test prompt', workFolder: '/tmp' } } }); const response = JSON.parse(result.content[0].text); expect(response.pid).toBe(12345); expect(response.status).toBe('started'); expect(response.message).toBe('Claude Code process started successfully'); }); it('should handle process with model parameter', async () => { const { handlers } = await setupServer(); const mockProcess = new EventEmitter() as any; mockProcess.pid = 12346; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockSpawn.mockReturnValue(mockProcess); const callToolHandler = handlers.get('callTool')!; await callToolHandler!({ params: { name: 'claude_code', arguments: { prompt: 'test prompt', workFolder: '/tmp', model: 'opus' } } }); expect(mockSpawn).toHaveBeenCalledWith( expect.any(String), expect.arrayContaining(['--model', 'opus']), expect.any(Object) ); }); it('should handle Japanese prompts with newlines', async () => { const { handlers } = await setupServer(); const mockProcess = new EventEmitter() as any; mockProcess.pid = 12360; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockSpawn.mockReturnValue(mockProcess); const japanesePrompt = `日本語のテストプロンプトです。 これは改行を含んでいます。 さらに、特殊文字も含みます:「こんにちは」、『世界』 最後の行です。`; const callToolHandler = handlers.get('callTool')!; const result = await callToolHandler!({ params: { name: 'claude_code', arguments: { prompt: japanesePrompt, workFolder: '/tmp' } } }); // Verify PID is returned const response = JSON.parse(result.content[0].text); expect(response.pid).toBe(12360); // Verify spawn was called with the correct prompt including newlines expect(mockSpawn).toHaveBeenCalledWith( expect.any(String), expect.arrayContaining(['-p', japanesePrompt]), expect.any(Object) ); // Verify the prompt is stored correctly in process manager const getResult = await callToolHandler!({ params: { name: 'get_claude_result', arguments: { pid: 12360 } } }); const processInfo = JSON.parse(getResult.content[0].text); expect(processInfo.prompt).toBe(japanesePrompt); }); it('should handle very long Japanese prompts with multiple paragraphs', async () => { const { handlers } = await setupServer(); const mockProcess = new EventEmitter() as any; mockProcess.pid = 12361; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockSpawn.mockReturnValue(mockProcess); const longJapanesePrompt = `# タスク:ファイル管理システムの作成 以下の要件に従って、ファイル管理システムを作成してください: 1. **基本機能** - ファイルの作成、読み取り、更新、削除(CRUD) - ディレクトリの作成と管理 - ファイルの検索機能 2. **追加機能** - ファイルのバージョン管理 - アクセス権限の設定 - ログ記録機能 3. **技術要件** - TypeScriptを使用 - テストコードを含める - ドキュメントを日本語で作成 注意事項: - エラーハンドリングを適切に行う - パフォーマンスを考慮した実装 - セキュリティに配慮すること よろしくお願いします。`; const callToolHandler = handlers.get('callTool')!; const result = await callToolHandler!({ params: { name: 'claude_code', arguments: { prompt: longJapanesePrompt, workFolder: '/tmp', model: 'sonnet' } } }); // Verify PID is returned const response = JSON.parse(result.content[0].text); expect(response.pid).toBe(12361); // Verify spawn was called with the complete long prompt expect(mockSpawn).toHaveBeenCalledWith( expect.any(String), expect.arrayContaining(['-p', longJapanesePrompt]), expect.any(Object) ); // Check list_claude_processes truncates long prompts correctly const listResult = await callToolHandler!({ params: { name: 'list_claude_processes', arguments: {} } }); const processes = JSON.parse(listResult.content[0].text); const process = processes.find((p: any) => p.pid === 12361); expect(process.prompt).toHaveLength(103); // 100 chars + '...' expect(process.prompt.endsWith('...')).toBe(true); expect(process.prompt).toContain('タスク:ファイル管理システムの作成'); }); it('should handle prompts with special characters and escape sequences', async () => { const { handlers } = await setupServer(); const mockProcess = new EventEmitter() as any; mockProcess.pid = 12362; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockSpawn.mockReturnValue(mockProcess); // Test with various special characters const specialPrompt = `特殊文字のテスト: \t- タブ文字 \n- 明示的な改行 "ダブルクォート"'シングルクォート' バックスラッシュ: \\ Unicodeテスト: 🎌 🗾 ✨ 環境変数風: $HOME と \${USER}`; const callToolHandler = handlers.get('callTool')!; const result = await callToolHandler!({ params: { name: 'claude_code', arguments: { prompt: specialPrompt, workFolder: '/tmp' } } }); // Verify PID is returned const response = JSON.parse(result.content[0].text); expect(response.pid).toBe(12362); // Verify spawn was called with the special characters intact expect(mockSpawn).toHaveBeenCalledWith( expect.any(String), expect.arrayContaining(['-p', specialPrompt]), expect.any(Object) ); }); it('should throw error if process fails to start', async () => { const { handlers } = await setupServer(); const mockProcess = new EventEmitter() as any; mockProcess.pid = undefined; // No PID means process failed to start mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockSpawn.mockReturnValue(mockProcess); const callToolHandler = handlers.get('callTool')!; await expect(callToolHandler!({ params: { name: 'claude_code', arguments: { prompt: 'test prompt', workFolder: '/tmp/test' } } })).rejects.toThrow('Failed to start Claude CLI process'); }); }); describe('list_claude_processes tool', () => { it('should list all processes', async () => { const { handlers } = await setupServer(); // Start a process first const mockProcess = new EventEmitter() as any; mockProcess.pid = 12347; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockSpawn.mockReturnValue(mockProcess); const callToolHandler = handlers.get('callTool')!; // Start a process await callToolHandler!({ params: { name: 'claude_code', arguments: { prompt: 'test prompt for listing', workFolder: '/tmp', model: 'sonnet' } } }); // Simulate JSON output with session_id const jsonOutput = { session_id: 'list-test-session-789', status: 'running' }; mockProcess.stdout.emit('data', JSON.stringify(jsonOutput)); // List processes const listResult = await callToolHandler!({ params: { name: 'list_claude_processes', arguments: {} } }); const processes = JSON.parse(listResult.content[0].text); expect(processes).toHaveLength(1); expect(processes[0].pid).toBe(12347); expect(processes[0].status).toBe('running'); expect(processes[0].prompt).toContain('test prompt for listing'); expect(processes[0].model).toBe('sonnet'); expect(processes[0].session_id).toBe('list-test-session-789'); }); it('should truncate long prompts in list', async () => { const { handlers } = await setupServer(); const mockProcess = new EventEmitter() as any; mockProcess.pid = 12348; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockSpawn.mockReturnValue(mockProcess); const callToolHandler = handlers.get('callTool'); // Start a process with a very long prompt const longPrompt = 'a'.repeat(150); await callToolHandler!({ params: { name: 'claude_code', arguments: { prompt: longPrompt, workFolder: '/tmp' } } }); // List processes const listResult = await callToolHandler!({ params: { name: 'list_claude_processes', arguments: {} } }); const processes = JSON.parse(listResult.content[0].text); expect(processes[0].prompt).toHaveLength(103); // 100 chars + '...' expect(processes[0].prompt.endsWith('...')).toBe(true); }); }); describe('get_claude_result tool', () => { it('should get process output', async () => { const { handlers } = await setupServer(); const mockProcess = new EventEmitter() as any; mockProcess.pid = 12349; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockSpawn.mockReturnValue(mockProcess); const callToolHandler = handlers.get('callTool')!; // Start a process await callToolHandler!({ params: { name: 'claude_code', arguments: { prompt: 'test prompt', workFolder: '/tmp' } } }); // Simulate JSON output from Claude CLI const claudeJsonOutput = { session_id: 'test-session-123', status: 'success', message: 'Task completed' }; mockProcess.stdout.emit('data', JSON.stringify(claudeJsonOutput)); mockProcess.stderr.emit('data', 'Warning from stderr\n'); // Get result const result = await callToolHandler!({ params: { name: 'get_claude_result', arguments: { pid: 12349 } } }); const processInfo = JSON.parse(result.content[0].text); expect(processInfo.pid).toBe(12349); expect(processInfo.status).toBe('running'); expect(processInfo.claudeOutput).toEqual(claudeJsonOutput); expect(processInfo.session_id).toBe('test-session-123'); }); it('should show completed status when process exits', async () => { const { handlers } = await setupServer(); const mockProcess = new EventEmitter() as any; mockProcess.pid = 12350; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockSpawn.mockReturnValue(mockProcess); const callToolHandler = handlers.get('callTool')!; // Start a process await callToolHandler!({ params: { name: 'claude_code', arguments: { prompt: 'test prompt', workFolder: '/tmp' } } }); // Simulate process completion with JSON output const completedJsonOutput = { session_id: 'completed-session-456', status: 'completed', files_created: ['test.txt'], summary: 'Created test file successfully' }; mockProcess.stdout.emit('data', JSON.stringify(completedJsonOutput)); mockProcess.emit('close', 0); // Get result const result = await callToolHandler!({ params: { name: 'get_claude_result', arguments: { pid: 12350 } } }); const processInfo = JSON.parse(result.content[0].text); expect(processInfo.status).toBe('completed'); expect(processInfo.exitCode).toBe(0); expect(processInfo.claudeOutput).toEqual(completedJsonOutput); expect(processInfo.session_id).toBe('completed-session-456'); }); it('should throw error for non-existent PID', async () => { const { handlers } = await setupServer(); const callToolHandler = handlers.get('callTool'); await expect(callToolHandler!({ params: { name: 'get_claude_result', arguments: { pid: 99999 } } })).rejects.toThrow('Process with PID 99999 not found'); }); it('should throw error for invalid PID parameter', async () => { const { handlers } = await setupServer(); const callToolHandler = handlers.get('callTool'); await expect(callToolHandler!({ params: { name: 'get_claude_result', arguments: { pid: 'not-a-number' } } })).rejects.toThrow('Missing or invalid required parameter: pid'); }); it('should handle non-JSON output gracefully', async () => { const { handlers } = await setupServer(); const mockProcess = new EventEmitter() as any; mockProcess.pid = 12355; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockSpawn.mockReturnValue(mockProcess); const callToolHandler = handlers.get('callTool')!; // Start a process await callToolHandler!({ params: { name: 'claude_code', arguments: { prompt: 'test prompt', workFolder: '/tmp' } } }); // Simulate non-JSON output mockProcess.stdout.emit('data', 'This is plain text output, not JSON'); mockProcess.stderr.emit('data', 'Some error occurred'); // Get result const result = await callToolHandler!({ params: { name: 'get_claude_result', arguments: { pid: 12355 } } }); const processInfo = JSON.parse(result.content[0].text); expect(processInfo.pid).toBe(12355); expect(processInfo.status).toBe('running'); expect(processInfo.stdout).toBe('This is plain text output, not JSON'); expect(processInfo.stderr).toBe('Some error occurred'); expect(processInfo.claudeOutput).toBeUndefined(); expect(processInfo.session_id).toBeUndefined(); }); }); describe('kill_claude_process tool', () => { it('should kill a running process', async () => { const { handlers } = await setupServer(); const mockProcess = new EventEmitter() as any; mockProcess.pid = 12351; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockSpawn.mockReturnValue(mockProcess); const callToolHandler = handlers.get('callTool')!; // Start a process await callToolHandler!({ params: { name: 'claude_code', arguments: { prompt: 'test prompt', workFolder: '/tmp' } } }); // Kill the process const killResult = await callToolHandler!({ params: { name: 'kill_claude_process', arguments: { pid: 12351 } } }); const response = JSON.parse(killResult.content[0].text); expect(response.status).toBe('terminated'); expect(response.message).toBe('Process terminated successfully'); expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM'); }); it('should handle already terminated process', async () => { const { handlers } = await setupServer(); const mockProcess = new EventEmitter() as any; mockProcess.pid = 12352; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockSpawn.mockReturnValue(mockProcess); const callToolHandler = handlers.get('callTool'); // Start and complete a process await callToolHandler!({ params: { name: 'claude_code', arguments: { prompt: 'test prompt', workFolder: '/tmp' } } }); // Simulate process completion mockProcess.emit('close', 0); // Try to kill the already completed process const killResult = await callToolHandler!({ params: { name: 'kill_claude_process', arguments: { pid: 12352 } } }); const response = JSON.parse(killResult.content[0].text); expect(response.status).toBe('completed'); expect(response.message).toBe('Process already terminated'); expect(mockProcess.kill).not.toHaveBeenCalled(); }); it('should throw error for non-existent PID', async () => { const { handlers } = await setupServer(); const callToolHandler = handlers.get('callTool'); await expect(callToolHandler!({ params: { name: 'kill_claude_process', arguments: { pid: 99999 } } })).rejects.toThrow('Process with PID 99999 not found'); }); }); describe('Tool routing', () => { it('should throw error for unknown tool', async () => { const { handlers } = await setupServer(); const callToolHandler = handlers.get('callTool'); await expect(callToolHandler!({ params: { name: 'unknown_tool', arguments: {} } })).rejects.toThrow('Tool unknown_tool not found'); }); }); describe('Process error handling', () => { it('should handle process errors', async () => { const { handlers } = await setupServer(); const mockProcess = new EventEmitter() as any; mockProcess.pid = 12353; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockSpawn.mockReturnValue(mockProcess); const callToolHandler = handlers.get('callTool')!; // Start a process await callToolHandler!({ params: { name: 'claude_code', arguments: { prompt: 'test prompt', workFolder: '/tmp' } } }); // Simulate process error mockProcess.emit('error', new Error('spawn error')); // Get result to check error was recorded const result = await callToolHandler!({ params: { name: 'get_claude_result', arguments: { pid: 12353 } } }); const processInfo = JSON.parse(result.content[0].text); expect(processInfo.status).toBe('failed'); expect(processInfo.stderr).toContain('Process error: spawn error'); }); }); });