Skip to content

Commit 5051759

Browse files
authored
feat: add codex mcp client (#157)
1 parent e7f3388 commit 5051759

File tree

3 files changed

+257
-0
lines changed

3 files changed

+257
-0
lines changed
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { CodexMCPClient } from '../codex';
2+
import { getDefaultServerConfig } from '../../defaults';
3+
4+
jest.mock('node:child_process', () => ({
5+
execSync: jest.fn(),
6+
spawnSync: jest.fn(),
7+
}));
8+
9+
jest.mock('../../defaults', () => ({
10+
getDefaultServerConfig: jest.fn(),
11+
}));
12+
13+
jest.mock('../../../../utils/analytics', () => ({
14+
analytics: {
15+
captureException: jest.fn(),
16+
},
17+
}));
18+
19+
describe('CodexMCPClient', () => {
20+
const { execSync, spawnSync } = require('node:child_process');
21+
const analytics = require('../../../../utils/analytics').analytics;
22+
const getDefaultServerConfigMock = getDefaultServerConfig as jest.Mock;
23+
24+
const spawnSyncMock = spawnSync as jest.Mock;
25+
const execSyncMock = execSync as jest.Mock;
26+
27+
const mockConfig = {
28+
command: 'npx',
29+
args: ['-y', 'mcp-remote@latest', 'https://example.com'],
30+
env: {
31+
POSTHOG_AUTH_HEADER: 'Bearer phx_example',
32+
},
33+
};
34+
35+
beforeEach(() => {
36+
jest.clearAllMocks();
37+
getDefaultServerConfigMock.mockReturnValue(mockConfig);
38+
});
39+
40+
describe('isClientSupported', () => {
41+
it('returns true when codex binary is available', async () => {
42+
execSyncMock.mockReturnValue(undefined);
43+
44+
const client = new CodexMCPClient();
45+
await expect(client.isClientSupported()).resolves.toBe(true);
46+
expect(execSyncMock).toHaveBeenCalledWith('codex --version', {
47+
stdio: 'ignore',
48+
});
49+
});
50+
51+
it('returns false when codex binary is missing', async () => {
52+
execSyncMock.mockImplementation(() => {
53+
throw new Error('not found');
54+
});
55+
56+
const client = new CodexMCPClient();
57+
await expect(client.isClientSupported()).resolves.toBe(false);
58+
});
59+
});
60+
61+
describe('isServerInstalled', () => {
62+
it('returns true when posthog server exists', async () => {
63+
spawnSyncMock.mockReturnValue({
64+
status: 0,
65+
stdout: JSON.stringify([{ name: 'posthog' }, { name: 'other' }]),
66+
});
67+
68+
const client = new CodexMCPClient();
69+
await expect(client.isServerInstalled()).resolves.toBe(true);
70+
});
71+
72+
it('returns false when command fails', async () => {
73+
spawnSyncMock.mockReturnValue({ status: 1, stdout: '' });
74+
75+
const client = new CodexMCPClient();
76+
await expect(client.isServerInstalled()).resolves.toBe(false);
77+
});
78+
});
79+
80+
describe('addServer', () => {
81+
it('invokes codex mcp add with expected arguments', async () => {
82+
spawnSyncMock.mockReturnValue({ status: 0 });
83+
84+
const client = new CodexMCPClient();
85+
const result = await client.addServer('phx_example');
86+
87+
expect(result).toEqual({ success: true });
88+
expect(spawnSyncMock).toHaveBeenCalledWith(
89+
'codex',
90+
[
91+
'mcp',
92+
'add',
93+
'posthog',
94+
'--env',
95+
'POSTHOG_AUTH_HEADER=Bearer phx_example',
96+
'--',
97+
'npx',
98+
'-y',
99+
'mcp-remote@latest',
100+
'https://example.com',
101+
],
102+
{ stdio: 'ignore' },
103+
);
104+
});
105+
106+
it('returns false and captures exception on failure', async () => {
107+
spawnSyncMock.mockReturnValue({ status: 1 });
108+
109+
const client = new CodexMCPClient();
110+
const result = await client.addServer('phx_example');
111+
112+
expect(result).toEqual({ success: false });
113+
expect(analytics.captureException).toHaveBeenCalled();
114+
});
115+
});
116+
117+
describe('removeServer', () => {
118+
it('invokes codex mcp remove and returns success', async () => {
119+
spawnSyncMock.mockReturnValue({ status: 0 });
120+
121+
const client = new CodexMCPClient();
122+
const result = await client.removeServer();
123+
124+
expect(result).toEqual({ success: true });
125+
expect(spawnSyncMock).toHaveBeenCalledWith(
126+
'codex',
127+
['mcp', 'remove', 'posthog'],
128+
{
129+
stdio: 'ignore',
130+
},
131+
);
132+
});
133+
134+
it('returns false and captures exception on failure', async () => {
135+
spawnSyncMock.mockReturnValue({ status: 1 });
136+
137+
const client = new CodexMCPClient();
138+
const result = await client.removeServer();
139+
140+
expect(result).toEqual({ success: false });
141+
expect(analytics.captureException).toHaveBeenCalled();
142+
});
143+
});
144+
});
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { z } from 'zod';
2+
import { execSync, spawnSync } from 'node:child_process';
3+
4+
import { DefaultMCPClient } from '../MCPClient';
5+
import { DefaultMCPClientConfig, getDefaultServerConfig } from '../defaults';
6+
7+
import { analytics } from '../../../utils/analytics';
8+
9+
export const CodexMCPConfig = DefaultMCPClientConfig;
10+
11+
export type CodexMCPConfig = z.infer<typeof DefaultMCPClientConfig>;
12+
13+
export class CodexMCPClient extends DefaultMCPClient {
14+
name = 'Codex CLI';
15+
16+
constructor() {
17+
super();
18+
}
19+
20+
isClientSupported(): Promise<boolean> {
21+
try {
22+
execSync('codex --version', { stdio: 'ignore' });
23+
return Promise.resolve(true);
24+
} catch {
25+
return Promise.resolve(false);
26+
}
27+
}
28+
29+
getConfigPath(): Promise<string> {
30+
throw new Error('Not implemented');
31+
}
32+
33+
isServerInstalled(local?: boolean): Promise<boolean> {
34+
const serverName = local ? 'posthog-local' : 'posthog';
35+
36+
try {
37+
const result = spawnSync('codex', ['mcp', 'list', '--json'], {
38+
encoding: 'utf-8',
39+
});
40+
41+
if (result.error || result.status !== 0) {
42+
return Promise.resolve(false);
43+
}
44+
45+
const stdout = result.stdout?.trim();
46+
if (!stdout) {
47+
return Promise.resolve(false);
48+
}
49+
50+
const servers = JSON.parse(stdout) as Array<{ name: string }>;
51+
return Promise.resolve(
52+
servers.some((server) => server.name === serverName),
53+
);
54+
} catch {
55+
return Promise.resolve(false);
56+
}
57+
}
58+
59+
addServer(
60+
apiKey: string,
61+
selectedFeatures?: string[],
62+
local?: boolean,
63+
): Promise<{ success: boolean }> {
64+
const config = getDefaultServerConfig(
65+
apiKey,
66+
'sse',
67+
selectedFeatures,
68+
local,
69+
);
70+
const serverName = local ? 'posthog-local' : 'posthog';
71+
72+
const args = ['mcp', 'add', serverName];
73+
74+
if (config.env) {
75+
for (const [key, value] of Object.entries(config.env)) {
76+
args.push('--env', `${key}=${value}`);
77+
}
78+
}
79+
80+
args.push('--', config.command, ...(config.args ?? []));
81+
82+
const result = spawnSync('codex', args, { stdio: 'ignore' });
83+
84+
if (result.error || result.status !== 0) {
85+
analytics.captureException(
86+
new Error('Failed to add server to Codex CLI.'),
87+
);
88+
return Promise.resolve({ success: false });
89+
}
90+
91+
return Promise.resolve({ success: true });
92+
}
93+
94+
removeServer(local?: boolean): Promise<{ success: boolean }> {
95+
const serverName = local ? 'posthog-local' : 'posthog';
96+
const result = spawnSync('codex', ['mcp', 'remove', serverName], {
97+
stdio: 'ignore',
98+
});
99+
100+
if (result.error || result.status !== 0) {
101+
analytics.captureException(
102+
new Error('Failed to remove server from Codex CLI.'),
103+
);
104+
return Promise.resolve({ success: false });
105+
}
106+
107+
return Promise.resolve({ success: true });
108+
}
109+
}
110+
111+
export default CodexMCPClient;

src/steps/add-mcp-server-to-clients/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { CloudRegion } from '../../utils/types';
1212
import { ClaudeCodeMCPClient } from './clients/claude-code';
1313
import { VisualStudioCodeClient } from './clients/visual-studio-code';
1414
import { ZedClient } from './clients/zed';
15+
import { CodexMCPClient } from './clients/codex';
1516
import { AVAILABLE_FEATURES, ALL_FEATURE_VALUES } from './defaults';
1617
import { debug } from '../../utils/debug';
1718

@@ -22,6 +23,7 @@ export const getSupportedClients = async (): Promise<MCPClient[]> => {
2223
new ClaudeCodeMCPClient(),
2324
new VisualStudioCodeClient(),
2425
new ZedClient(),
26+
new CodexMCPClient(),
2527
];
2628
const supportedClients: MCPClient[] = [];
2729

0 commit comments

Comments
 (0)