ai-cli-mcp 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/dist/__tests__/e2e.test.js +225 -0
  3. package/dist/__tests__/edge-cases.test.js +127 -0
  4. package/dist/__tests__/error-cases.test.js +291 -0
  5. package/dist/__tests__/mocks.js +32 -0
  6. package/dist/__tests__/model-alias.test.js +36 -0
  7. package/dist/__tests__/process-management.test.js +630 -0
  8. package/dist/__tests__/server.test.js +681 -0
  9. package/dist/__tests__/setup.js +11 -0
  10. package/dist/__tests__/utils/claude-mock.js +80 -0
  11. package/dist/__tests__/utils/mcp-client.js +121 -0
  12. package/dist/__tests__/utils/persistent-mock.js +25 -0
  13. package/dist/__tests__/utils/test-helpers.js +11 -0
  14. package/dist/__tests__/validation.test.js +235 -0
  15. package/dist/__tests__/version-print.test.js +65 -0
  16. package/dist/__tests__/wait.test.js +229 -0
  17. package/dist/parsers.js +68 -0
  18. package/dist/server.js +772 -0
  19. package/package.json +1 -1
  20. package/src/__tests__/e2e.test.ts +19 -34
  21. package/src/__tests__/edge-cases.test.ts +5 -14
  22. package/src/__tests__/error-cases.test.ts +8 -17
  23. package/src/__tests__/process-management.test.ts +22 -24
  24. package/src/__tests__/utils/mcp-client.ts +30 -0
  25. package/src/__tests__/validation.test.ts +58 -36
  26. package/src/__tests__/version-print.test.ts +5 -10
  27. package/src/server.ts +5 -3
  28. package/data/rooms/refactor-haiku-alias-main/messages.jsonl +0 -5
  29. package/data/rooms/refactor-haiku-alias-main/presence.json +0 -20
  30. package/data/rooms.json +0 -10
  31. package/hello.txt +0 -3
  32. package/implementation-log.md +0 -110
  33. package/implementation-plan.md +0 -189
  34. package/investigation-report.md +0 -135
  35. package/quality-score.json +0 -47
  36. package/refactoring-requirements.md +0 -25
  37. package/review-report.md +0 -132
  38. package/test-results.md +0 -119
  39. package/xx.txt +0 -1
package/dist/server.js ADDED
@@ -0,0 +1,772 @@
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 { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
5
+ import { spawn } from 'node:child_process';
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join, resolve as pathResolve } from 'node:path';
9
+ import * as path from 'path';
10
+ import { parseCodexOutput, parseClaudeOutput, parseGeminiOutput } from './parsers.js';
11
+ // Server version - update this when releasing new versions
12
+ const SERVER_VERSION = "2.2.0";
13
+ // Model alias mappings for user-friendly model names
14
+ const MODEL_ALIASES = {
15
+ 'haiku': 'claude-3-5-haiku-20241022'
16
+ };
17
+ // Define debugMode globally using const
18
+ const debugMode = process.env.MCP_CLAUDE_DEBUG === 'true';
19
+ // Track if this is the first tool use for version printing
20
+ let isFirstToolUse = true;
21
+ // Capture server startup time when the module loads
22
+ const serverStartupTime = new Date().toISOString();
23
+ // Global process manager
24
+ const processManager = new Map();
25
+ // Dedicated debug logging function
26
+ export function debugLog(message, ...optionalParams) {
27
+ if (debugMode) {
28
+ console.error(message, ...optionalParams);
29
+ }
30
+ }
31
+ /**
32
+ * Determine the Gemini CLI command/path.
33
+ * Similar to findClaudeCli but for Gemini
34
+ */
35
+ export function findGeminiCli() {
36
+ debugLog('[Debug] Attempting to find Gemini CLI...');
37
+ // Check for custom CLI name from environment variable
38
+ const customCliName = process.env.GEMINI_CLI_NAME;
39
+ if (customCliName) {
40
+ debugLog(`[Debug] Using custom Gemini CLI name from GEMINI_CLI_NAME: ${customCliName}`);
41
+ // If it's an absolute path, use it directly
42
+ if (path.isAbsolute(customCliName)) {
43
+ debugLog(`[Debug] GEMINI_CLI_NAME is an absolute path: ${customCliName}`);
44
+ return customCliName;
45
+ }
46
+ // If it starts with ~ or ./, reject as relative paths are not allowed
47
+ if (customCliName.startsWith('./') || customCliName.startsWith('../') || customCliName.includes('/')) {
48
+ throw new Error(`Invalid GEMINI_CLI_NAME: Relative paths are not allowed. Use either a simple name (e.g., 'gemini') or an absolute path (e.g., '/tmp/gemini-test')`);
49
+ }
50
+ }
51
+ const cliName = customCliName || 'gemini';
52
+ // Try local install path: ~/.gemini/local/gemini
53
+ const userPath = join(homedir(), '.gemini', 'local', 'gemini');
54
+ debugLog(`[Debug] Checking for Gemini CLI at local user path: ${userPath}`);
55
+ if (existsSync(userPath)) {
56
+ debugLog(`[Debug] Found Gemini CLI at local user path: ${userPath}. Using this path.`);
57
+ return userPath;
58
+ }
59
+ else {
60
+ debugLog(`[Debug] Gemini CLI not found at local user path: ${userPath}.`);
61
+ }
62
+ // Fallback to CLI name (PATH lookup)
63
+ debugLog(`[Debug] Falling back to "${cliName}" command name, relying on spawn/PATH lookup.`);
64
+ console.warn(`[Warning] Gemini CLI not found at ~/.gemini/local/gemini. Falling back to "${cliName}" in PATH. Ensure it is installed and accessible.`);
65
+ return cliName;
66
+ }
67
+ /**
68
+ * Determine the Codex CLI command/path.
69
+ * Similar to findClaudeCli but for Codex
70
+ */
71
+ export function findCodexCli() {
72
+ debugLog('[Debug] Attempting to find Codex CLI...');
73
+ // Check for custom CLI name from environment variable
74
+ const customCliName = process.env.CODEX_CLI_NAME;
75
+ if (customCliName) {
76
+ debugLog(`[Debug] Using custom Codex CLI name from CODEX_CLI_NAME: ${customCliName}`);
77
+ // If it's an absolute path, use it directly
78
+ if (path.isAbsolute(customCliName)) {
79
+ debugLog(`[Debug] CODEX_CLI_NAME is an absolute path: ${customCliName}`);
80
+ return customCliName;
81
+ }
82
+ // If it starts with ~ or ./, reject as relative paths are not allowed
83
+ if (customCliName.startsWith('./') || customCliName.startsWith('../') || customCliName.includes('/')) {
84
+ throw new Error(`Invalid CODEX_CLI_NAME: Relative paths are not allowed. Use either a simple name (e.g., 'codex') or an absolute path (e.g., '/tmp/codex-test')`);
85
+ }
86
+ }
87
+ const cliName = customCliName || 'codex';
88
+ // Try local install path: ~/.codex/local/codex
89
+ const userPath = join(homedir(), '.codex', 'local', 'codex');
90
+ debugLog(`[Debug] Checking for Codex CLI at local user path: ${userPath}`);
91
+ if (existsSync(userPath)) {
92
+ debugLog(`[Debug] Found Codex CLI at local user path: ${userPath}. Using this path.`);
93
+ return userPath;
94
+ }
95
+ else {
96
+ debugLog(`[Debug] Codex CLI not found at local user path: ${userPath}.`);
97
+ }
98
+ // Fallback to CLI name (PATH lookup)
99
+ debugLog(`[Debug] Falling back to "${cliName}" command name, relying on spawn/PATH lookup.`);
100
+ console.warn(`[Warning] Codex CLI not found at ~/.codex/local/codex. Falling back to "${cliName}" in PATH. Ensure it is installed and accessible.`);
101
+ return cliName;
102
+ }
103
+ /**
104
+ * Determine the Claude CLI command/path.
105
+ * 1. Checks for CLAUDE_CLI_NAME environment variable:
106
+ * - If absolute path, uses it directly
107
+ * - If relative path, throws error
108
+ * - If simple name, continues with path resolution
109
+ * 2. Checks for Claude CLI at the local user path: ~/.claude/local/claude.
110
+ * 3. If not found, defaults to the CLI name (or 'claude'), relying on the system's PATH for lookup.
111
+ */
112
+ export function findClaudeCli() {
113
+ debugLog('[Debug] Attempting to find Claude CLI...');
114
+ // Check for custom CLI name from environment variable
115
+ const customCliName = process.env.CLAUDE_CLI_NAME;
116
+ if (customCliName) {
117
+ debugLog(`[Debug] Using custom Claude CLI name from CLAUDE_CLI_NAME: ${customCliName}`);
118
+ // If it's an absolute path, use it directly
119
+ if (path.isAbsolute(customCliName)) {
120
+ debugLog(`[Debug] CLAUDE_CLI_NAME is an absolute path: ${customCliName}`);
121
+ return customCliName;
122
+ }
123
+ // If it starts with ~ or ./, reject as relative paths are not allowed
124
+ if (customCliName.startsWith('./') || customCliName.startsWith('../') || customCliName.includes('/')) {
125
+ throw new Error(`Invalid CLAUDE_CLI_NAME: Relative paths are not allowed. Use either a simple name (e.g., 'claude') or an absolute path (e.g., '/tmp/claude-test')`);
126
+ }
127
+ }
128
+ const cliName = customCliName || 'claude';
129
+ // Try local install path: ~/.claude/local/claude (using the original name for local installs)
130
+ const userPath = join(homedir(), '.claude', 'local', 'claude');
131
+ debugLog(`[Debug] Checking for Claude CLI at local user path: ${userPath}`);
132
+ if (existsSync(userPath)) {
133
+ debugLog(`[Debug] Found Claude CLI at local user path: ${userPath}. Using this path.`);
134
+ return userPath;
135
+ }
136
+ else {
137
+ debugLog(`[Debug] Claude CLI not found at local user path: ${userPath}.`);
138
+ }
139
+ // 3. Fallback to CLI name (PATH lookup)
140
+ debugLog(`[Debug] Falling back to "${cliName}" command name, relying on spawn/PATH lookup.`);
141
+ console.warn(`[Warning] Claude CLI not found at ~/.claude/local/claude. Falling back to "${cliName}" in PATH. Ensure it is installed and accessible.`);
142
+ return cliName;
143
+ }
144
+ /**
145
+ * Resolves model aliases to their full model names
146
+ * @param model - The model name or alias to resolve
147
+ * @returns The full model name, or the original value if no alias exists
148
+ */
149
+ export function resolveModelAlias(model) {
150
+ return MODEL_ALIASES[model] || model;
151
+ }
152
+ // Ensure spawnAsync is defined correctly *before* the class
153
+ export async function spawnAsync(command, args, options) {
154
+ return new Promise((resolve, reject) => {
155
+ debugLog(`[Spawn] Running command: ${command} ${args.join(' ')}`);
156
+ const process = spawn(command, args, {
157
+ shell: false, // Reverted to false
158
+ timeout: options?.timeout,
159
+ cwd: options?.cwd,
160
+ stdio: ['ignore', 'pipe', 'pipe']
161
+ });
162
+ let stdout = '';
163
+ let stderr = '';
164
+ process.stdout.on('data', (data) => { stdout += data.toString(); });
165
+ process.stderr.on('data', (data) => {
166
+ stderr += data.toString();
167
+ debugLog(`[Spawn Stderr Chunk] ${data.toString()}`);
168
+ });
169
+ process.on('error', (error) => {
170
+ debugLog(`[Spawn Error Event] Full error object:`, error);
171
+ let errorMessage = `Spawn error: ${error.message}`;
172
+ if (error.path) {
173
+ errorMessage += ` | Path: ${error.path}`;
174
+ }
175
+ if (error.syscall) {
176
+ errorMessage += ` | Syscall: ${error.syscall}`;
177
+ }
178
+ errorMessage += `\nStderr: ${stderr.trim()}`;
179
+ reject(new Error(errorMessage));
180
+ });
181
+ process.on('close', (code) => {
182
+ debugLog(`[Spawn Close] Exit code: ${code}`);
183
+ debugLog(`[Spawn Stderr Full] ${stderr.trim()}`);
184
+ debugLog(`[Spawn Stdout Full] ${stdout.trim()}`);
185
+ if (code === 0) {
186
+ resolve({ stdout, stderr });
187
+ }
188
+ else {
189
+ reject(new Error(`Command failed with exit code ${code}\nStderr: ${stderr.trim()}\nStdout: ${stdout.trim()}`));
190
+ }
191
+ });
192
+ });
193
+ }
194
+ /**
195
+ * MCP Server for Claude Code
196
+ * Provides a simple MCP tool to run Claude CLI in one-shot mode
197
+ */
198
+ export class ClaudeCodeServer {
199
+ server;
200
+ claudeCliPath;
201
+ codexCliPath;
202
+ geminiCliPath;
203
+ sigintHandler;
204
+ packageVersion;
205
+ constructor() {
206
+ // Use the simplified findClaudeCli function
207
+ this.claudeCliPath = findClaudeCli(); // Removed debugMode argument
208
+ this.codexCliPath = findCodexCli();
209
+ this.geminiCliPath = findGeminiCli();
210
+ console.error(`[Setup] Using Claude CLI command/path: ${this.claudeCliPath}`);
211
+ console.error(`[Setup] Using Codex CLI command/path: ${this.codexCliPath}`);
212
+ console.error(`[Setup] Using Gemini CLI command/path: ${this.geminiCliPath}`);
213
+ this.packageVersion = SERVER_VERSION;
214
+ this.server = new Server({
215
+ name: 'ai_cli_mcp',
216
+ version: SERVER_VERSION,
217
+ }, {
218
+ capabilities: {
219
+ tools: {},
220
+ },
221
+ });
222
+ this.setupToolHandlers();
223
+ this.server.onerror = (error) => console.error('[Error]', error);
224
+ this.sigintHandler = async () => {
225
+ await this.server.close();
226
+ process.exit(0);
227
+ };
228
+ process.on('SIGINT', this.sigintHandler);
229
+ }
230
+ /**
231
+ * Set up the MCP tool handlers
232
+ */
233
+ setupToolHandlers() {
234
+ // Define available tools
235
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
236
+ tools: [
237
+ {
238
+ name: 'run',
239
+ description: `AI Agent Runner: Starts a Claude, Codex, or Gemini CLI process in the background and returns a PID immediately. Use list_processes and get_result to monitor progress.
240
+
241
+ • File ops: Create, read, (fuzzy) edit, move, copy, delete, list files, analyze/ocr images, file content analysis
242
+ • Code: Generate / analyse / refactor / fix
243
+ • Git: Stage ▸ commit ▸ push ▸ tag (any workflow)
244
+ • Terminal: Run any CLI cmd or open URLs
245
+ • Web search + summarise content on-the-fly
246
+ • Multi-step workflows & GitHub integration
247
+
248
+ **IMPORTANT**: This tool now returns immediately with a PID. Use other tools to check status and get results.
249
+
250
+ **Supported models**:
251
+ "sonnet", "opus", "haiku", "gpt-5-low", "gpt-5-medium", "gpt-5-high", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-3-pro-preview"
252
+
253
+ **Prompt input**: You must provide EITHER prompt (string) OR prompt_file (file path), but not both.
254
+
255
+ **Prompt tips**
256
+ 1. Be concise, explicit & step-by-step for complex tasks.
257
+ 2. Check process status with list_processes
258
+ 3. Get results with get_result using the returned PID
259
+ 4. Kill long-running processes with kill_process if needed
260
+
261
+ `,
262
+ inputSchema: {
263
+ type: 'object',
264
+ properties: {
265
+ prompt: {
266
+ type: 'string',
267
+ description: 'The detailed natural language prompt for the agent to execute. Either this or prompt_file is required.',
268
+ },
269
+ prompt_file: {
270
+ type: 'string',
271
+ description: 'Path to a file containing the prompt. Either this or prompt is required. Must be an absolute path or relative to workFolder.',
272
+ },
273
+ workFolder: {
274
+ type: 'string',
275
+ description: 'The working directory for the agent execution. Must be an absolute path.',
276
+ },
277
+ model: {
278
+ type: 'string',
279
+ description: 'The model to use: "sonnet", "opus", "haiku", "gpt-5-low", "gpt-5-medium", "gpt-5-high", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-3-pro-preview".',
280
+ },
281
+ session_id: {
282
+ type: 'string',
283
+ description: 'Optional session ID to resume a previous session. Supported for: haiku, sonnet, opus, gemini-2.5-pro, gemini-2.5-flash, gemini-3-pro-preview.',
284
+ },
285
+ },
286
+ required: ['workFolder'],
287
+ },
288
+ },
289
+ {
290
+ name: 'list_processes',
291
+ description: 'List all running and completed AI agent processes. Returns a simple list with PID, agent type, and status for each process.',
292
+ inputSchema: {
293
+ type: 'object',
294
+ properties: {},
295
+ },
296
+ },
297
+ {
298
+ name: 'get_result',
299
+ description: 'Get the current output and status of an AI agent process by PID. Returns the output from the agent including session_id (if applicable), along with process metadata.',
300
+ inputSchema: {
301
+ type: 'object',
302
+ properties: {
303
+ pid: {
304
+ type: 'number',
305
+ description: 'The process ID returned by run tool.',
306
+ },
307
+ },
308
+ required: ['pid'],
309
+ },
310
+ },
311
+ {
312
+ name: 'wait',
313
+ description: 'Wait for multiple AI agent processes to complete and return their results. Blocks until all specified PIDs finish or timeout occurs.',
314
+ inputSchema: {
315
+ type: 'object',
316
+ properties: {
317
+ pids: {
318
+ type: 'array',
319
+ items: { type: 'number' },
320
+ description: 'List of process IDs to wait for (returned by the run tool).',
321
+ },
322
+ timeout: {
323
+ type: 'number',
324
+ description: 'Optional: Maximum time to wait in seconds. Defaults to 180 (3 minutes).',
325
+ },
326
+ },
327
+ required: ['pids'],
328
+ },
329
+ },
330
+ {
331
+ name: 'kill_process',
332
+ description: 'Terminate a running AI agent process by PID.',
333
+ inputSchema: {
334
+ type: 'object',
335
+ properties: {
336
+ pid: {
337
+ type: 'number',
338
+ description: 'The process ID to terminate.',
339
+ },
340
+ },
341
+ required: ['pid'],
342
+ },
343
+ },
344
+ {
345
+ name: 'cleanup_processes',
346
+ description: 'Remove all completed and failed processes from the process list to free up memory.',
347
+ inputSchema: {
348
+ type: 'object',
349
+ properties: {},
350
+ },
351
+ }
352
+ ],
353
+ }));
354
+ // Handle tool calls
355
+ const executionTimeoutMs = 1800000; // 30 minutes timeout
356
+ this.server.setRequestHandler(CallToolRequestSchema, async (args, call) => {
357
+ debugLog('[Debug] Handling CallToolRequest:', args);
358
+ const toolName = args.params.name;
359
+ const toolArguments = args.params.arguments || {};
360
+ switch (toolName) {
361
+ case 'run':
362
+ return this.handleRun(toolArguments);
363
+ case 'list_processes':
364
+ return this.handleListProcesses();
365
+ case 'get_result':
366
+ return this.handleGetResult(toolArguments);
367
+ case 'wait':
368
+ return this.handleWait(toolArguments);
369
+ case 'kill_process':
370
+ return this.handleKillProcess(toolArguments);
371
+ case 'cleanup_processes':
372
+ return this.handleCleanupProcesses();
373
+ default:
374
+ throw new McpError(ErrorCode.MethodNotFound, `Tool ${toolName} not found`);
375
+ }
376
+ });
377
+ }
378
+ /**
379
+ * Handle run tool - starts Claude or Codex process and returns PID immediately
380
+ */
381
+ async handleRun(toolArguments) {
382
+ // Validate workFolder is required
383
+ if (!toolArguments.workFolder || typeof toolArguments.workFolder !== 'string') {
384
+ throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid required parameter: workFolder');
385
+ }
386
+ // Validate that either prompt or prompt_file is provided
387
+ const hasPrompt = toolArguments.prompt && typeof toolArguments.prompt === 'string' && toolArguments.prompt.trim() !== '';
388
+ const hasPromptFile = toolArguments.prompt_file && typeof toolArguments.prompt_file === 'string' && toolArguments.prompt_file.trim() !== '';
389
+ if (!hasPrompt && !hasPromptFile) {
390
+ throw new McpError(ErrorCode.InvalidParams, 'Either prompt or prompt_file must be provided');
391
+ }
392
+ if (hasPrompt && hasPromptFile) {
393
+ throw new McpError(ErrorCode.InvalidParams, 'Cannot specify both prompt and prompt_file. Please use only one.');
394
+ }
395
+ // Determine the prompt to use
396
+ let prompt;
397
+ if (hasPrompt) {
398
+ prompt = toolArguments.prompt;
399
+ }
400
+ else {
401
+ // Read prompt from file
402
+ const promptFilePath = path.isAbsolute(toolArguments.prompt_file)
403
+ ? toolArguments.prompt_file
404
+ : pathResolve(toolArguments.workFolder, toolArguments.prompt_file);
405
+ if (!existsSync(promptFilePath)) {
406
+ throw new McpError(ErrorCode.InvalidParams, `Prompt file does not exist: ${promptFilePath}`);
407
+ }
408
+ try {
409
+ prompt = readFileSync(promptFilePath, 'utf-8');
410
+ }
411
+ catch (error) {
412
+ throw new McpError(ErrorCode.InvalidParams, `Failed to read prompt file: ${error.message}`);
413
+ }
414
+ }
415
+ // Determine working directory
416
+ const resolvedCwd = pathResolve(toolArguments.workFolder);
417
+ if (!existsSync(resolvedCwd)) {
418
+ throw new McpError(ErrorCode.InvalidParams, `Working folder does not exist: ${toolArguments.workFolder}`);
419
+ }
420
+ const effectiveCwd = resolvedCwd;
421
+ // Print version on first use
422
+ if (isFirstToolUse) {
423
+ console.error(`ai_cli_mcp v${SERVER_VERSION} started at ${serverStartupTime}`);
424
+ isFirstToolUse = false;
425
+ }
426
+ // Determine which agent to use based on model name
427
+ const model = toolArguments.model || '';
428
+ let agent;
429
+ if (model.startsWith('gpt-')) {
430
+ agent = 'codex';
431
+ }
432
+ else if (model.startsWith('gemini')) {
433
+ agent = 'gemini';
434
+ }
435
+ else {
436
+ agent = 'claude';
437
+ }
438
+ let cliPath;
439
+ let processArgs;
440
+ if (agent === 'codex') {
441
+ // Handle Codex
442
+ cliPath = this.codexCliPath;
443
+ processArgs = ['exec'];
444
+ // Parse model format for Codex (e.g., gpt-5-low -> model: gpt-5, effort: low)
445
+ if (toolArguments.model) {
446
+ // Split by "gpt-5-" to get the effort level
447
+ const effort = toolArguments.model.replace('gpt-5-', '');
448
+ if (effort && effort !== toolArguments.model) {
449
+ processArgs.push('-c', `model_reasoning_effort=${effort}`);
450
+ }
451
+ processArgs.push('--model', 'gpt-5');
452
+ }
453
+ processArgs.push('--full-auto', '--json', prompt);
454
+ }
455
+ else if (agent === 'gemini') {
456
+ // Handle Gemini
457
+ cliPath = this.geminiCliPath;
458
+ processArgs = ['-y', '--output-format', 'json'];
459
+ // Add session_id if provided
460
+ if (toolArguments.session_id && typeof toolArguments.session_id === 'string') {
461
+ processArgs.push('-r', toolArguments.session_id);
462
+ }
463
+ // Add model if specified
464
+ if (toolArguments.model) {
465
+ processArgs.push('--model', toolArguments.model);
466
+ }
467
+ // Add prompt as positional argument
468
+ processArgs.push(prompt);
469
+ }
470
+ else {
471
+ // Handle Claude (default)
472
+ cliPath = this.claudeCliPath;
473
+ processArgs = ['--dangerously-skip-permissions', '--output-format', 'json'];
474
+ // Add session_id if provided (Claude only)
475
+ if (toolArguments.session_id && typeof toolArguments.session_id === 'string') {
476
+ processArgs.push('-r', toolArguments.session_id);
477
+ }
478
+ processArgs.push('-p', prompt);
479
+ if (toolArguments.model && typeof toolArguments.model === 'string') {
480
+ const resolvedModel = resolveModelAlias(toolArguments.model);
481
+ processArgs.push('--model', resolvedModel);
482
+ }
483
+ }
484
+ // Spawn process without waiting
485
+ const childProcess = spawn(cliPath, processArgs, {
486
+ cwd: effectiveCwd,
487
+ stdio: ['ignore', 'pipe', 'pipe'],
488
+ detached: false
489
+ });
490
+ const pid = childProcess.pid;
491
+ if (!pid) {
492
+ throw new McpError(ErrorCode.InternalError, `Failed to start ${agent} CLI process`);
493
+ }
494
+ // Create process tracking entry
495
+ const processEntry = {
496
+ pid,
497
+ process: childProcess,
498
+ prompt,
499
+ workFolder: effectiveCwd,
500
+ model: toolArguments.model,
501
+ toolType: agent,
502
+ startTime: new Date().toISOString(),
503
+ stdout: '',
504
+ stderr: '',
505
+ status: 'running'
506
+ };
507
+ // Track the process
508
+ processManager.set(pid, processEntry);
509
+ // Set up output collection
510
+ childProcess.stdout.on('data', (data) => {
511
+ const entry = processManager.get(pid);
512
+ if (entry) {
513
+ entry.stdout += data.toString();
514
+ }
515
+ });
516
+ childProcess.stderr.on('data', (data) => {
517
+ const entry = processManager.get(pid);
518
+ if (entry) {
519
+ entry.stderr += data.toString();
520
+ }
521
+ });
522
+ childProcess.on('close', (code) => {
523
+ const entry = processManager.get(pid);
524
+ if (entry) {
525
+ entry.status = code === 0 ? 'completed' : 'failed';
526
+ entry.exitCode = code !== null ? code : undefined;
527
+ }
528
+ });
529
+ childProcess.on('error', (error) => {
530
+ const entry = processManager.get(pid);
531
+ if (entry) {
532
+ entry.status = 'failed';
533
+ entry.stderr += `\nProcess error: ${error.message}`;
534
+ }
535
+ });
536
+ // Return PID immediately
537
+ return {
538
+ content: [{
539
+ type: 'text',
540
+ text: JSON.stringify({
541
+ pid,
542
+ status: 'started',
543
+ agent,
544
+ message: `${agent} process started successfully`
545
+ }, null, 2)
546
+ }]
547
+ };
548
+ }
549
+ /**
550
+ * Handle list_processes tool
551
+ */
552
+ async handleListProcesses() {
553
+ const processes = [];
554
+ for (const [pid, process] of processManager.entries()) {
555
+ const processInfo = {
556
+ pid,
557
+ agent: process.toolType,
558
+ status: process.status
559
+ };
560
+ processes.push(processInfo);
561
+ }
562
+ return {
563
+ content: [{
564
+ type: 'text',
565
+ text: JSON.stringify(processes, null, 2)
566
+ }]
567
+ };
568
+ }
569
+ /**
570
+ * Helper to get process result object
571
+ */
572
+ getProcessResultHelper(pid) {
573
+ const process = processManager.get(pid);
574
+ if (!process) {
575
+ throw new McpError(ErrorCode.InvalidParams, `Process with PID ${pid} not found`);
576
+ }
577
+ // Parse output based on agent type
578
+ let agentOutput = null;
579
+ if (process.stdout) {
580
+ if (process.toolType === 'codex') {
581
+ agentOutput = parseCodexOutput(process.stdout);
582
+ }
583
+ else if (process.toolType === 'claude') {
584
+ agentOutput = parseClaudeOutput(process.stdout);
585
+ }
586
+ else if (process.toolType === 'gemini') {
587
+ agentOutput = parseGeminiOutput(process.stdout);
588
+ }
589
+ }
590
+ // Construct response with agent's output and process metadata
591
+ const response = {
592
+ pid,
593
+ agent: process.toolType,
594
+ status: process.status,
595
+ exitCode: process.exitCode,
596
+ startTime: process.startTime,
597
+ workFolder: process.workFolder,
598
+ prompt: process.prompt,
599
+ model: process.model
600
+ };
601
+ // If we have valid output from agent, include it
602
+ if (agentOutput) {
603
+ response.agentOutput = agentOutput;
604
+ // Extract session_id if available (Claude and Gemini)
605
+ if ((process.toolType === 'claude' || process.toolType === 'gemini') && agentOutput.session_id) {
606
+ response.session_id = agentOutput.session_id;
607
+ }
608
+ }
609
+ else {
610
+ // Fallback to raw output
611
+ response.stdout = process.stdout;
612
+ response.stderr = process.stderr;
613
+ }
614
+ return response;
615
+ }
616
+ /**
617
+ * Handle get_result tool
618
+ */
619
+ async handleGetResult(toolArguments) {
620
+ if (!toolArguments.pid || typeof toolArguments.pid !== 'number') {
621
+ throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid required parameter: pid');
622
+ }
623
+ const pid = toolArguments.pid;
624
+ const response = this.getProcessResultHelper(pid);
625
+ return {
626
+ content: [{
627
+ type: 'text',
628
+ text: JSON.stringify(response, null, 2)
629
+ }]
630
+ };
631
+ }
632
+ /**
633
+ * Handle wait tool
634
+ */
635
+ async handleWait(toolArguments) {
636
+ if (!toolArguments.pids || !Array.isArray(toolArguments.pids) || toolArguments.pids.length === 0) {
637
+ throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid required parameter: pids (must be a non-empty array of numbers)');
638
+ }
639
+ const pids = toolArguments.pids;
640
+ // Default timeout: 3 minutes (180 seconds)
641
+ const timeoutSeconds = typeof toolArguments.timeout === 'number' ? toolArguments.timeout : 180;
642
+ const timeoutMs = timeoutSeconds * 1000;
643
+ // Validate all PIDs exist first
644
+ for (const pid of pids) {
645
+ if (!processManager.has(pid)) {
646
+ throw new McpError(ErrorCode.InvalidParams, `Process with PID ${pid} not found`);
647
+ }
648
+ }
649
+ // Create promises for each process
650
+ const waitPromises = pids.map(pid => {
651
+ const processEntry = processManager.get(pid);
652
+ if (processEntry.status !== 'running') {
653
+ return Promise.resolve();
654
+ }
655
+ return new Promise((resolve) => {
656
+ processEntry.process.once('close', () => {
657
+ resolve();
658
+ });
659
+ });
660
+ });
661
+ // Create a timeout promise
662
+ const timeoutPromise = new Promise((_, reject) => {
663
+ setTimeout(() => {
664
+ reject(new Error(`Timed out after ${timeoutSeconds} seconds waiting for processes`));
665
+ }, timeoutMs);
666
+ });
667
+ try {
668
+ // Wait for all processes to finish or timeout
669
+ await Promise.race([Promise.all(waitPromises), timeoutPromise]);
670
+ }
671
+ catch (error) {
672
+ throw new McpError(ErrorCode.InternalError, error.message);
673
+ }
674
+ // Collect results
675
+ const results = pids.map(pid => this.getProcessResultHelper(pid));
676
+ return {
677
+ content: [{
678
+ type: 'text',
679
+ text: JSON.stringify(results, null, 2)
680
+ }]
681
+ };
682
+ }
683
+ /**
684
+ * Handle kill_process tool
685
+ */
686
+ async handleKillProcess(toolArguments) {
687
+ if (!toolArguments.pid || typeof toolArguments.pid !== 'number') {
688
+ throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid required parameter: pid');
689
+ }
690
+ const pid = toolArguments.pid;
691
+ const processEntry = processManager.get(pid);
692
+ if (!processEntry) {
693
+ throw new McpError(ErrorCode.InvalidParams, `Process with PID ${pid} not found`);
694
+ }
695
+ if (processEntry.status !== 'running') {
696
+ return {
697
+ content: [{
698
+ type: 'text',
699
+ text: JSON.stringify({
700
+ pid,
701
+ status: processEntry.status,
702
+ message: 'Process already terminated'
703
+ }, null, 2)
704
+ }]
705
+ };
706
+ }
707
+ try {
708
+ processEntry.process.kill('SIGTERM');
709
+ processEntry.status = 'failed';
710
+ processEntry.stderr += '\nProcess terminated by user';
711
+ return {
712
+ content: [{
713
+ type: 'text',
714
+ text: JSON.stringify({
715
+ pid,
716
+ status: 'terminated',
717
+ message: 'Process terminated successfully'
718
+ }, null, 2)
719
+ }]
720
+ };
721
+ }
722
+ catch (error) {
723
+ throw new McpError(ErrorCode.InternalError, `Failed to terminate process: ${error.message}`);
724
+ }
725
+ }
726
+ /**
727
+ * Handle cleanup_processes tool
728
+ */
729
+ async handleCleanupProcesses() {
730
+ const removedPids = [];
731
+ // Iterate through all processes and collect PIDs to remove
732
+ for (const [pid, process] of processManager.entries()) {
733
+ if (process.status === 'completed' || process.status === 'failed') {
734
+ removedPids.push(pid);
735
+ processManager.delete(pid);
736
+ }
737
+ }
738
+ return {
739
+ content: [{
740
+ type: 'text',
741
+ text: JSON.stringify({
742
+ removed: removedPids.length,
743
+ removedPids,
744
+ message: `Cleaned up ${removedPids.length} finished process(es)`
745
+ }, null, 2)
746
+ }]
747
+ };
748
+ }
749
+ /**
750
+ * Start the MCP server
751
+ */
752
+ async run() {
753
+ // Revert to original server start logic if listen caused errors
754
+ const transport = new StdioServerTransport();
755
+ await this.server.connect(transport);
756
+ console.error('AI CLI MCP server running on stdio');
757
+ }
758
+ /**
759
+ * Clean up resources (for testing)
760
+ */
761
+ async cleanup() {
762
+ if (this.sigintHandler) {
763
+ process.removeListener('SIGINT', this.sigintHandler);
764
+ }
765
+ await this.server.close();
766
+ }
767
+ }
768
+ // Create and run the server (skip during tests)
769
+ if (!process.env.VITEST) {
770
+ const server = new ClaudeCodeServer();
771
+ server.run().catch(console.error);
772
+ }