From b3ab144f7ec84929161f19b96b70c26e598a08b9 Mon Sep 17 00:00:00 2001 From: Mansehej Singh Date: Wed, 15 Oct 2025 21:09:58 +0100 Subject: [PATCH 1/2] feat: add codex mcp client --- .../clients/__tests__/codex.test.ts | 144 ++++++++++++++++++ .../clients/codex.ts | 108 +++++++++++++ src/steps/add-mcp-server-to-clients/index.ts | 2 + 3 files changed, 254 insertions(+) create mode 100644 src/steps/add-mcp-server-to-clients/clients/__tests__/codex.test.ts create mode 100644 src/steps/add-mcp-server-to-clients/clients/codex.ts diff --git a/src/steps/add-mcp-server-to-clients/clients/__tests__/codex.test.ts b/src/steps/add-mcp-server-to-clients/clients/__tests__/codex.test.ts new file mode 100644 index 0000000..29e9a74 --- /dev/null +++ b/src/steps/add-mcp-server-to-clients/clients/__tests__/codex.test.ts @@ -0,0 +1,144 @@ +import { CodexMCPClient } from '../codex'; +import { getDefaultServerConfig } from '../../defaults'; + +jest.mock('node:child_process', () => ({ + execSync: jest.fn(), + spawnSync: jest.fn(), +})); + +jest.mock('../../defaults', () => ({ + getDefaultServerConfig: jest.fn(), +})); + +jest.mock('../../../../utils/analytics', () => ({ + analytics: { + captureException: jest.fn(), + }, +})); + +describe('CodexMCPClient', () => { + const { execSync, spawnSync } = require('node:child_process'); + const analytics = require('../../../../utils/analytics').analytics; + const getDefaultServerConfigMock = getDefaultServerConfig as jest.Mock; + + const spawnSyncMock = spawnSync as jest.Mock; + const execSyncMock = execSync as jest.Mock; + + const mockConfig = { + command: 'npx', + args: ['-y', 'mcp-remote@latest', 'https://example.com'], + env: { + POSTHOG_AUTH_HEADER: 'Bearer phx_example', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + getDefaultServerConfigMock.mockReturnValue(mockConfig); + }); + + describe('isClientSupported', () => { + it('returns true when codex binary is available', async () => { + execSyncMock.mockReturnValue(undefined); + + const client = new CodexMCPClient(); + await expect(client.isClientSupported()).resolves.toBe(true); + expect(execSyncMock).toHaveBeenCalledWith('codex --version', { + stdio: 'ignore', + }); + }); + + it('returns false when codex binary is missing', async () => { + execSyncMock.mockImplementation(() => { + throw new Error('not found'); + }); + + const client = new CodexMCPClient(); + await expect(client.isClientSupported()).resolves.toBe(false); + }); + }); + + describe('isServerInstalled', () => { + it('returns true when posthog server exists', async () => { + spawnSyncMock.mockReturnValue({ + status: 0, + stdout: JSON.stringify([{ name: 'posthog' }, { name: 'other' }]), + }); + + const client = new CodexMCPClient(); + await expect(client.isServerInstalled()).resolves.toBe(true); + }); + + it('returns false when command fails', async () => { + spawnSyncMock.mockReturnValue({ status: 1, stdout: '' }); + + const client = new CodexMCPClient(); + await expect(client.isServerInstalled()).resolves.toBe(false); + }); + }); + + describe('addServer', () => { + it('invokes codex mcp add with expected arguments', async () => { + spawnSyncMock.mockReturnValue({ status: 0 }); + + const client = new CodexMCPClient(); + const result = await client.addServer('phx_example'); + + expect(result).toEqual({ success: true }); + expect(spawnSyncMock).toHaveBeenCalledWith( + 'codex', + [ + 'mcp', + 'add', + 'posthog', + '--env', + 'POSTHOG_AUTH_HEADER=Bearer phx_example', + '--', + 'npx', + '-y', + 'mcp-remote@latest', + 'https://example.com', + ], + { stdio: 'ignore' }, + ); + }); + + it('returns false and captures exception on failure', async () => { + spawnSyncMock.mockReturnValue({ status: 1 }); + + const client = new CodexMCPClient(); + const result = await client.addServer('phx_example'); + + expect(result).toEqual({ success: false }); + expect(analytics.captureException).toHaveBeenCalled(); + }); + }); + + describe('removeServer', () => { + it('invokes codex mcp remove and returns success', async () => { + spawnSyncMock.mockReturnValue({ status: 0 }); + + const client = new CodexMCPClient(); + const result = await client.removeServer(); + + expect(result).toEqual({ success: true }); + expect(spawnSyncMock).toHaveBeenCalledWith( + 'codex', + ['mcp', 'remove', 'posthog'], + { + stdio: 'ignore', + }, + ); + }); + + it('returns false and captures exception on failure', async () => { + spawnSyncMock.mockReturnValue({ status: 1 }); + + const client = new CodexMCPClient(); + const result = await client.removeServer(); + + expect(result).toEqual({ success: false }); + expect(analytics.captureException).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/steps/add-mcp-server-to-clients/clients/codex.ts b/src/steps/add-mcp-server-to-clients/clients/codex.ts new file mode 100644 index 0000000..be05c20 --- /dev/null +++ b/src/steps/add-mcp-server-to-clients/clients/codex.ts @@ -0,0 +1,108 @@ +import { DefaultMCPClient } from '../MCPClient'; +import { getDefaultServerConfig } from '../defaults'; +import { analytics } from '../../../utils/analytics'; +import { execSync, spawnSync } from 'node:child_process'; + +export class CodexMCPClient extends DefaultMCPClient { + name = 'Codex CLI'; + + constructor() { + super(); + } + + isClientSupported(): Promise { + try { + execSync('codex --version', { stdio: 'ignore' }); + return Promise.resolve(true); + } catch { + return Promise.resolve(false); + } + } + + getConfigPath(): Promise { + throw new Error('Not implemented'); + } + + isServerInstalled(local?: boolean): Promise { + const serverName = local ? 'posthog-local' : 'posthog'; + + try { + const result = spawnSync('codex', ['mcp', 'list', '--json'], { + encoding: 'utf-8', + }); + + if (result.error || result.status !== 0) { + return Promise.resolve(false); + } + + const stdout = result.stdout?.trim(); + if (!stdout) { + return Promise.resolve(false); + } + + const servers = JSON.parse(stdout) as Array<{ name: string }>; + return Promise.resolve( + servers.some((server) => server.name === serverName), + ); + } catch { + return Promise.resolve(false); + } + } + + addServer( + apiKey: string, + selectedFeatures?: string[], + local?: boolean, + ): Promise<{ success: boolean }> { + const config = getDefaultServerConfig( + apiKey, + 'sse', + selectedFeatures, + local, + ); + const serverName = local ? 'posthog-local' : 'posthog'; + + const args = ['mcp', 'add', serverName]; + + if (config.env) { + for (const [key, value] of Object.entries(config.env)) { + args.push('--env', `${key}=${value}`); + } + } + + args.push('--', config.command, ...(config.args ?? [])); + + const result = spawnSync('codex', args, { stdio: 'ignore' }); + + if (result.error || result.status !== 0) { + analytics.captureException( + new Error( + 'Failed to add server to Codex CLI. Please ensure codex is installed.', + ), + ); + return Promise.resolve({ success: false }); + } + + return Promise.resolve({ success: true }); + } + + removeServer(local?: boolean): Promise<{ success: boolean }> { + const serverName = local ? 'posthog-local' : 'posthog'; + const result = spawnSync('codex', ['mcp', 'remove', serverName], { + stdio: 'ignore', + }); + + if (result.error || result.status !== 0) { + analytics.captureException( + new Error( + 'Failed to remove server from Codex CLI. Please ensure codex is installed.', + ), + ); + return Promise.resolve({ success: false }); + } + + return Promise.resolve({ success: true }); + } +} + +export default CodexMCPClient; diff --git a/src/steps/add-mcp-server-to-clients/index.ts b/src/steps/add-mcp-server-to-clients/index.ts index 12d3d5e..393d5c1 100644 --- a/src/steps/add-mcp-server-to-clients/index.ts +++ b/src/steps/add-mcp-server-to-clients/index.ts @@ -12,6 +12,7 @@ import type { CloudRegion } from '../../utils/types'; import { ClaudeCodeMCPClient } from './clients/claude-code'; import { VisualStudioCodeClient } from './clients/visual-studio-code'; import { ZedClient } from './clients/zed'; +import { CodexMCPClient } from './clients/codex'; import { AVAILABLE_FEATURES, ALL_FEATURE_VALUES } from './defaults'; export const getSupportedClients = async (): Promise => { @@ -21,6 +22,7 @@ export const getSupportedClients = async (): Promise => { new ClaudeCodeMCPClient(), new VisualStudioCodeClient(), new ZedClient(), + new CodexMCPClient(), ]; const supportedClients: MCPClient[] = []; From b8db952aaef6717d203abe41affa4784d84e83bb Mon Sep 17 00:00:00 2001 From: Mansehej Singh Date: Wed, 15 Oct 2025 21:32:42 +0100 Subject: [PATCH 2/2] chore: add type exports --- .../clients/codex.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/steps/add-mcp-server-to-clients/clients/codex.ts b/src/steps/add-mcp-server-to-clients/clients/codex.ts index be05c20..18bf543 100644 --- a/src/steps/add-mcp-server-to-clients/clients/codex.ts +++ b/src/steps/add-mcp-server-to-clients/clients/codex.ts @@ -1,7 +1,14 @@ +import { z } from 'zod'; +import { execSync, spawnSync } from 'node:child_process'; + import { DefaultMCPClient } from '../MCPClient'; -import { getDefaultServerConfig } from '../defaults'; +import { DefaultMCPClientConfig, getDefaultServerConfig } from '../defaults'; + import { analytics } from '../../../utils/analytics'; -import { execSync, spawnSync } from 'node:child_process'; + +export const CodexMCPConfig = DefaultMCPClientConfig; + +export type CodexMCPConfig = z.infer; export class CodexMCPClient extends DefaultMCPClient { name = 'Codex CLI'; @@ -76,9 +83,7 @@ export class CodexMCPClient extends DefaultMCPClient { if (result.error || result.status !== 0) { analytics.captureException( - new Error( - 'Failed to add server to Codex CLI. Please ensure codex is installed.', - ), + new Error('Failed to add server to Codex CLI.'), ); return Promise.resolve({ success: false }); } @@ -94,9 +99,7 @@ export class CodexMCPClient extends DefaultMCPClient { if (result.error || result.status !== 0) { analytics.captureException( - new Error( - 'Failed to remove server from Codex CLI. Please ensure codex is installed.', - ), + new Error('Failed to remove server from Codex CLI.'), ); return Promise.resolve({ success: false }); }