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