Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions src/steps/add-mcp-server-to-clients/clients/__tests__/codex.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
111 changes: 111 additions & 0 deletions src/steps/add-mcp-server-to-clients/clients/codex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { z } from 'zod';
import { execSync, spawnSync } from 'node:child_process';

import { DefaultMCPClient } from '../MCPClient';
import { DefaultMCPClientConfig, getDefaultServerConfig } from '../defaults';

import { analytics } from '../../../utils/analytics';

export const CodexMCPConfig = DefaultMCPClientConfig;

export type CodexMCPConfig = z.infer<typeof DefaultMCPClientConfig>;

export class CodexMCPClient extends DefaultMCPClient {
name = 'Codex CLI';

constructor() {
super();
}

isClientSupported(): Promise<boolean> {
try {
execSync('codex --version', { stdio: 'ignore' });
return Promise.resolve(true);
} catch {
return Promise.resolve(false);
}
}

getConfigPath(): Promise<string> {
throw new Error('Not implemented');
}

isServerInstalled(local?: boolean): Promise<boolean> {
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.'),
);
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.'),
);
return Promise.resolve({ success: false });
}

return Promise.resolve({ success: true });
}
}

export default CodexMCPClient;
2 changes: 2 additions & 0 deletions src/steps/add-mcp-server-to-clients/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MCPClient[]> => {
Expand All @@ -21,6 +22,7 @@ export const getSupportedClients = async (): Promise<MCPClient[]> => {
new ClaudeCodeMCPClient(),
new VisualStudioCodeClient(),
new ZedClient(),
new CodexMCPClient(),
];
const supportedClients: MCPClient[] = [];

Expand Down