mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
init
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Codex MCP Client - Simple wrapper for Codex tools
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { logger } from '@/ui/logger';
|
||||
import type { CodexSessionConfig, CodexToolResponse } from './types';
|
||||
import { z } from 'zod';
|
||||
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { CodexPermissionHandler } from './utils/permissionHandler';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const DEFAULT_TIMEOUT = 14 * 24 * 60 * 60 * 1000; // 14 days, which is the half of the maximum possible timeout (~28 days for int32 value in NodeJS)
|
||||
|
||||
/**
|
||||
* Get the correct MCP subcommand based on installed codex version
|
||||
* Versions >= 0.43.0-alpha.5 use 'mcp-server', older versions use 'mcp'
|
||||
*/
|
||||
function getCodexMcpCommand(): string {
|
||||
try {
|
||||
const version = execSync('codex --version', { encoding: 'utf8' }).trim();
|
||||
const match = version.match(/codex-cli\s+(\d+\.\d+\.\d+(?:-alpha\.\d+)?)/);
|
||||
if (!match) return 'mcp-server'; // Default to newer command if we can't parse
|
||||
|
||||
const versionStr = match[1];
|
||||
const [major, minor, patch] = versionStr.split(/[-.]/).map(Number);
|
||||
|
||||
// Version >= 0.43.0-alpha.5 has mcp-server
|
||||
if (major > 0 || minor > 43) return 'mcp-server';
|
||||
if (minor === 43 && patch === 0) {
|
||||
// Check for alpha version
|
||||
if (versionStr.includes('-alpha.')) {
|
||||
const alphaNum = parseInt(versionStr.split('-alpha.')[1]);
|
||||
return alphaNum >= 5 ? 'mcp-server' : 'mcp';
|
||||
}
|
||||
return 'mcp-server'; // 0.43.0 stable has mcp-server
|
||||
}
|
||||
return 'mcp'; // Older versions use mcp
|
||||
} catch (error) {
|
||||
logger.debug('[CodexMCP] Error detecting codex version, defaulting to mcp-server:', error);
|
||||
return 'mcp-server'; // Default to newer command
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexMcpClient {
|
||||
private client: Client;
|
||||
private transport: StdioClientTransport | null = null;
|
||||
private connected: boolean = false;
|
||||
private sessionId: string | null = null;
|
||||
private conversationId: string | null = null;
|
||||
private handler: ((event: any) => void) | null = null;
|
||||
private permissionHandler: CodexPermissionHandler | null = null;
|
||||
|
||||
constructor() {
|
||||
this.client = new Client(
|
||||
{ name: 'happy-codex-client', version: '1.0.0' },
|
||||
{ capabilities: { elicitation: {} } }
|
||||
);
|
||||
|
||||
this.client.setNotificationHandler(z.object({
|
||||
method: z.literal('codex/event'),
|
||||
params: z.object({
|
||||
msg: z.any()
|
||||
})
|
||||
}).passthrough(), (data) => {
|
||||
const msg = data.params.msg;
|
||||
this.updateIdentifiersFromEvent(msg);
|
||||
this.handler?.(msg);
|
||||
});
|
||||
}
|
||||
|
||||
setHandler(handler: ((event: any) => void) | null): void {
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the permission handler for tool approval
|
||||
*/
|
||||
setPermissionHandler(handler: CodexPermissionHandler): void {
|
||||
this.permissionHandler = handler;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.connected) return;
|
||||
|
||||
const mcpCommand = getCodexMcpCommand();
|
||||
logger.debug(`[CodexMCP] Connecting to Codex MCP server using command: codex ${mcpCommand}`);
|
||||
|
||||
this.transport = new StdioClientTransport({
|
||||
command: 'codex',
|
||||
args: [mcpCommand],
|
||||
env: Object.keys(process.env).reduce((acc, key) => {
|
||||
const value = process.env[key];
|
||||
if (typeof value === 'string') acc[key] = value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>)
|
||||
});
|
||||
|
||||
// Register request handlers for Codex permission methods
|
||||
this.registerPermissionHandlers();
|
||||
|
||||
await this.client.connect(this.transport);
|
||||
this.connected = true;
|
||||
|
||||
logger.debug('[CodexMCP] Connected to Codex');
|
||||
}
|
||||
|
||||
private registerPermissionHandlers(): void {
|
||||
// Register handler for exec command approval requests
|
||||
this.client.setRequestHandler(
|
||||
ElicitRequestSchema,
|
||||
async (request) => {
|
||||
console.log('[CodexMCP] Received elicitation request:', request.params);
|
||||
|
||||
// Load params
|
||||
const params = request.params as unknown as {
|
||||
message: string,
|
||||
codex_elicitation: string,
|
||||
codex_mcp_tool_call_id: string,
|
||||
codex_event_id: string,
|
||||
codex_call_id: string,
|
||||
codex_command: string[],
|
||||
codex_cwd: string
|
||||
}
|
||||
const toolName = 'CodexBash';
|
||||
|
||||
// If no permission handler set, deny by default
|
||||
if (!this.permissionHandler) {
|
||||
logger.debug('[CodexMCP] No permission handler set, denying by default');
|
||||
return {
|
||||
decision: 'denied' as const,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Request permission through the handler
|
||||
const result = await this.permissionHandler.handleToolCall(
|
||||
params.codex_call_id,
|
||||
toolName,
|
||||
{
|
||||
command: params.codex_command,
|
||||
cwd: params.codex_cwd
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug('[CodexMCP] Permission result:', result);
|
||||
return {
|
||||
decision: result.decision
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('[CodexMCP] Error handling permission request:', error);
|
||||
return {
|
||||
decision: 'denied' as const,
|
||||
reason: error instanceof Error ? error.message : 'Permission request failed'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug('[CodexMCP] Permission handlers registered');
|
||||
}
|
||||
|
||||
async startSession(config: CodexSessionConfig, options?: { signal?: AbortSignal }): Promise<CodexToolResponse> {
|
||||
if (!this.connected) await this.connect();
|
||||
|
||||
logger.debug('[CodexMCP] Starting Codex session:', config);
|
||||
|
||||
const response = await this.client.callTool({
|
||||
name: 'codex',
|
||||
arguments: config as any
|
||||
}, undefined, {
|
||||
signal: options?.signal,
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
// maxTotalTimeout: 10000000000
|
||||
});
|
||||
|
||||
logger.debug('[CodexMCP] startSession response:', response);
|
||||
|
||||
// Extract session / conversation identifiers from response if present
|
||||
this.extractIdentifiers(response);
|
||||
|
||||
return response as CodexToolResponse;
|
||||
}
|
||||
|
||||
async continueSession(prompt: string, options?: { signal?: AbortSignal }): Promise<CodexToolResponse> {
|
||||
if (!this.connected) await this.connect();
|
||||
|
||||
if (!this.sessionId) {
|
||||
throw new Error('No active session. Call startSession first.');
|
||||
}
|
||||
|
||||
if (!this.conversationId) {
|
||||
// Some Codex deployments reuse the session ID as the conversation identifier
|
||||
this.conversationId = this.sessionId;
|
||||
logger.debug('[CodexMCP] conversationId missing, defaulting to sessionId:', this.conversationId);
|
||||
}
|
||||
|
||||
const args = { sessionId: this.sessionId, conversationId: this.conversationId, prompt };
|
||||
logger.debug('[CodexMCP] Continuing Codex session:', args);
|
||||
|
||||
const response = await this.client.callTool({
|
||||
name: 'codex-reply',
|
||||
arguments: args
|
||||
}, undefined, {
|
||||
signal: options?.signal,
|
||||
timeout: DEFAULT_TIMEOUT
|
||||
});
|
||||
|
||||
logger.debug('[CodexMCP] continueSession response:', response);
|
||||
this.extractIdentifiers(response);
|
||||
|
||||
return response as CodexToolResponse;
|
||||
}
|
||||
|
||||
|
||||
private updateIdentifiersFromEvent(event: any): void {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates: any[] = [event];
|
||||
if (event.data && typeof event.data === 'object') {
|
||||
candidates.push(event.data);
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const sessionId = candidate.session_id ?? candidate.sessionId;
|
||||
if (sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
logger.debug('[CodexMCP] Session ID extracted from event:', this.sessionId);
|
||||
}
|
||||
|
||||
const conversationId = candidate.conversation_id ?? candidate.conversationId;
|
||||
if (conversationId) {
|
||||
this.conversationId = conversationId;
|
||||
logger.debug('[CodexMCP] Conversation ID extracted from event:', this.conversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
private extractIdentifiers(response: any): void {
|
||||
const meta = response?.meta || {};
|
||||
if (meta.sessionId) {
|
||||
this.sessionId = meta.sessionId;
|
||||
logger.debug('[CodexMCP] Session ID extracted:', this.sessionId);
|
||||
} else if (response?.sessionId) {
|
||||
this.sessionId = response.sessionId;
|
||||
logger.debug('[CodexMCP] Session ID extracted:', this.sessionId);
|
||||
}
|
||||
|
||||
if (meta.conversationId) {
|
||||
this.conversationId = meta.conversationId;
|
||||
logger.debug('[CodexMCP] Conversation ID extracted:', this.conversationId);
|
||||
} else if (response?.conversationId) {
|
||||
this.conversationId = response.conversationId;
|
||||
logger.debug('[CodexMCP] Conversation ID extracted:', this.conversationId);
|
||||
}
|
||||
|
||||
const content = response?.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
if (!this.sessionId && item?.sessionId) {
|
||||
this.sessionId = item.sessionId;
|
||||
logger.debug('[CodexMCP] Session ID extracted from content:', this.sessionId);
|
||||
}
|
||||
if (!this.conversationId && item && typeof item === 'object' && 'conversationId' in item && item.conversationId) {
|
||||
this.conversationId = item.conversationId;
|
||||
logger.debug('[CodexMCP] Conversation ID extracted from content:', this.conversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSessionId(): string | null {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
hasActiveSession(): boolean {
|
||||
return this.sessionId !== null;
|
||||
}
|
||||
|
||||
clearSession(): void {
|
||||
// Store the previous session ID before clearing for potential resume
|
||||
const previousSessionId = this.sessionId;
|
||||
this.sessionId = null;
|
||||
this.conversationId = null;
|
||||
logger.debug('[CodexMCP] Session cleared, previous sessionId:', previousSessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the current session ID without clearing it, useful for abort handling
|
||||
*/
|
||||
storeSessionForResume(): string | null {
|
||||
logger.debug('[CodexMCP] Storing session for potential resume:', this.sessionId);
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (!this.connected) return;
|
||||
|
||||
// Capture pid in case we need to force-kill
|
||||
const pid = this.transport?.pid ?? null;
|
||||
logger.debug(`[CodexMCP] Disconnecting; child pid=${pid ?? 'none'}`);
|
||||
|
||||
try {
|
||||
// Ask client to close the transport
|
||||
logger.debug('[CodexMCP] client.close begin');
|
||||
await this.client.close();
|
||||
logger.debug('[CodexMCP] client.close done');
|
||||
} catch (e) {
|
||||
logger.debug('[CodexMCP] Error closing client, attempting transport close directly', e);
|
||||
try {
|
||||
logger.debug('[CodexMCP] transport.close begin');
|
||||
await this.transport?.close?.();
|
||||
logger.debug('[CodexMCP] transport.close done');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// As a last resort, if child still exists, send SIGKILL
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, 0); // check if alive
|
||||
logger.debug('[CodexMCP] Child still alive, sending SIGKILL');
|
||||
try { process.kill(pid, 'SIGKILL'); } catch {}
|
||||
} catch { /* not running */ }
|
||||
}
|
||||
|
||||
this.transport = null;
|
||||
this.connected = false;
|
||||
this.sessionId = null;
|
||||
this.conversationId = null;
|
||||
|
||||
logger.debug('[CodexMCP] Disconnected');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user