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
Original file line number Diff line number Diff line change
Expand Up @@ -77,26 +77,72 @@ describe('CodexMCPClient', () => {
});

describe('isServerInstalled', () => {
it('delegates to isPluginInstalled', async () => {
readFileSyncMock.mockReturnValue(
'[marketplaces.posthog]\nsource_type = "git"\n',
);
it('returns true when posthog appears in mcp list output', async () => {
spawnSyncMock.mockReturnValue({
status: 0,
stdout: 'posthog\n',
stderr: '',
});
const client = new CodexMCPClient();
await expect(client.isServerInstalled()).resolves.toBe(true);
});

it('returns false when posthog is absent from mcp list output', async () => {
spawnSyncMock.mockReturnValue({
status: 0,
stdout: 'other-server\n',
stderr: '',
});
const client = new CodexMCPClient();
await expect(client.isServerInstalled()).resolves.toBe(false);
});

it('returns false when mcp list exits non-zero', async () => {
spawnSyncMock.mockReturnValue({ status: 1, stdout: '', stderr: 'err' });
const client = new CodexMCPClient();
await expect(client.isServerInstalled()).resolves.toBe(false);
});
});

describe('addServer', () => {
it('delegates to installPlugin — returns success when plugin installs', async () => {
it('runs codex mcp add with the resolved URL and returns success on exit 0', async () => {
spawnSyncMock.mockReturnValue({ status: 0, stderr: '' });
const client = new CodexMCPClient();
await expect(client.addServer()).resolves.toEqual({ success: true });
await expect(client.addServer('phx_test')).resolves.toEqual({
success: true,
});
const call = spawnSyncMock.mock.calls[0]!;
expect(call[0]).toBe(CODEX_PATH);
expect(call[1]).toEqual([
'mcp',
'add',
'posthog',
'--url',
'https://mcp.posthog.com/mcp',
'--bearer-token-env-var',
'POSTHOG_AUTH_HEADER',
]);
expect(call[2].env.POSTHOG_AUTH_HEADER).toBe('Bearer phx_test');
});

it('treats "already" stderr as success', async () => {
spawnSyncMock.mockReturnValue({
status: 1,
stderr: "Server 'posthog' already exists",
});
const client = new CodexMCPClient();
await expect(client.addServer('phx_test')).resolves.toEqual({
success: true,
});
});

it('delegates to installPlugin — returns failure when plugin fails', async () => {
it('returns failure and captures exception on unexpected error', async () => {
spawnSyncMock.mockReturnValue({ status: 1, stderr: 'network timeout' });
const client = new CodexMCPClient();
await expect(client.addServer()).resolves.toEqual({ success: false });
await expect(client.addServer('phx_test')).resolves.toEqual({
success: false,
});
expect(analytics.captureException).toHaveBeenCalled();
});
});

Expand Down
58 changes: 52 additions & 6 deletions src/steps/add-mcp-server-to-clients/clients/claude-code.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DefaultMCPClient } from '../MCPClient';
import { DefaultMCPClientConfig } from '../defaults';
import { DefaultMCPClientConfig, buildMCPUrl } from '../defaults';
import { PluginCapable, PluginInstallResult } from '../plugin-client';
import { z } from 'zod';
import { execSync } from 'child_process';
Expand Down Expand Up @@ -85,17 +85,63 @@ export class ClaudeCodeMCPClient
}
}

isServerInstalled(): Promise<boolean> {
return this.isPluginInstalled();
isServerInstalled(local?: boolean): Promise<boolean> {
const binary = this.findClaudeBinary();
if (!binary) return Promise.resolve(false);
const serverName = local ? 'posthog-local' : 'posthog';
try {
const output = execSync(`${binary} mcp list`, { stdio: 'pipe' })
.toString()
.toLowerCase();
return Promise.resolve(output.includes(serverName));
} catch {
return Promise.resolve(false);
}
}

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

async addServer(): Promise<{ success: boolean }> {
const result = await this.installPlugin();
return { success: result.success };
addServer(
apiKey?: string,
selectedFeatures?: string[],
local?: boolean,
): Promise<{ success: boolean }> {
const binary = this.findClaudeBinary();
if (!binary) return Promise.resolve({ success: false });

const serverName = local ? 'posthog-local' : 'posthog';
const url = buildMCPUrl(selectedFeatures, local);
const args = [
'mcp',
'add',
'--transport',
'http',
'--scope',
'user',
serverName,
url,
];
if (apiKey) {
args.push('--header', `Authorization: Bearer ${apiKey}`);
}

try {
execSync(`${binary} ${args.map((a) => JSON.stringify(a)).join(' ')}`, {
stdio: 'pipe',
});
return Promise.resolve({ success: true });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes('already exists')) {
return Promise.resolve({ success: true });
}
analytics.captureException(
new Error(`Claude Code MCP add failed: ${msg}`),
);
return Promise.resolve({ success: false });
}
}

removeServer(local?: boolean): Promise<{ success: boolean }> {
Expand Down
44 changes: 38 additions & 6 deletions src/steps/add-mcp-server-to-clients/clients/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as os from 'node:os';
import * as path from 'node:path';

import { DefaultMCPClient } from '../MCPClient';
import { DefaultMCPClientConfig } from '../defaults';
import { DefaultMCPClientConfig, buildMCPUrl } from '../defaults';
import { PluginCapable, PluginInstallResult } from '../plugin-client';

import { analytics } from '../../../utils/analytics';
Expand Down Expand Up @@ -46,13 +46,45 @@ export class CodexMCPClient extends DefaultMCPClient implements PluginCapable {
throw new Error('Not implemented');
}

isServerInstalled(): Promise<boolean> {
return this.isPluginInstalled();
isServerInstalled(local?: boolean): Promise<boolean> {
const binary = this.findCodexBinary();
if (!binary) return Promise.resolve(false);
const serverName = local ? 'posthog-local' : 'posthog';
const result = spawnSync(binary, ['mcp', 'list'], { encoding: 'utf-8' });
if (result.status !== 0) return Promise.resolve(false);
return Promise.resolve(
(result.stdout ?? '').toLowerCase().includes(serverName),
);
}

async addServer(): Promise<{ success: boolean }> {
const result = await this.installPlugin();
return { success: result.success };
addServer(
apiKey?: string,
selectedFeatures?: string[],
local?: boolean,
): Promise<{ success: boolean }> {
const binary = this.findCodexBinary();
if (!binary) return Promise.resolve({ success: false });

const serverName = local ? 'posthog-local' : 'posthog';
const url = buildMCPUrl(selectedFeatures, local);
const args = ['mcp', 'add', serverName, '--url', url];
const env = { ...process.env };
if (apiKey) {
const tokenVar = 'POSTHOG_AUTH_HEADER';
env[tokenVar] = `Bearer ${apiKey}`;
args.push('--bearer-token-env-var', tokenVar);
}

const result = spawnSync(binary, args, { encoding: 'utf-8', env });
if (result.status !== 0) {
const stderr = result.stderr ?? '';
if (stderr.toLowerCase().includes('already')) {
return Promise.resolve({ success: true });
}
analytics.captureException(new Error(`Codex MCP add failed: ${stderr}`));
return Promise.resolve({ success: false });
}
return Promise.resolve({ success: true });
}

removeServer(): Promise<{ success: boolean }> {
Expand Down
46 changes: 35 additions & 11 deletions src/steps/add-mcp-server-to-clients/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,28 @@ export const AVAILABLE_FEATURES = {
label: 'SQL',
hint: 'SQL query execution',
},
{
value: 'web_analytics',
label: 'Web Analytics',
hint: 'Web analytics queries and digests',
},
{
value: 'customer_analytics',
label: 'Usage metrics',
hint: 'Customer usage metric tracking',
},
{
value: 'signals',
label: 'Signals',
hint: 'Signal reports and source configs',
},
],
'AI Engineering': [
{
value: 'llm_analytics',
label: 'LLM Analytics',
hint: 'LLM usage and cost tracking',
},
{
value: 'prompts',
label: 'Prompts',
hint: 'LLM prompt management',
},
],
'Development Tools': [
{
Expand Down Expand Up @@ -100,6 +110,16 @@ export const AVAILABLE_FEATURES = {
label: 'Cohorts',
hint: 'Cohort management',
},
{
value: 'sdk_doctor',
label: 'SDK Doctor',
hint: 'SDK health diagnostics',
},
{
value: 'tracing',
label: 'APM Tracing',
hint: 'Distributed trace and span queries',
},
],
'Data Management': [
{
Expand Down Expand Up @@ -132,6 +152,11 @@ export const AVAILABLE_FEATURES = {
label: 'Data Schema',
hint: 'Data schema exploration',
},
{
value: 'batch_exports',
label: 'Batch Exports',
hint: 'Scheduled data exports',
},
],
'CDP & Automation': [
{
Expand Down Expand Up @@ -213,22 +238,21 @@ export const ALL_FEATURE_VALUES = Object.values(AVAILABLE_FEATURES)
.flat()
.map((feature) => feature.value);

export const isAllFeaturesSelected = (features: string[]): boolean =>
features.length === ALL_FEATURE_VALUES.length &&
ALL_FEATURE_VALUES.every((feature) => features.includes(feature));

export const buildMCPUrl = (selectedFeatures?: string[], local?: boolean) => {
const host = local ? 'http://localhost:8787' : 'https://mcp.posthog.com';
const baseUrl = `${host}/mcp`;

const isAllFeaturesSelected =
selectedFeatures &&
selectedFeatures.length === ALL_FEATURE_VALUES.length &&
ALL_FEATURE_VALUES.every((feature) => selectedFeatures.includes(feature));

const params: string[] = [];

// Add features param if not all features selected
if (
selectedFeatures &&
selectedFeatures.length > 0 &&
!isAllFeaturesSelected
!isAllFeaturesSelected(selectedFeatures)
) {
params.push(`features=${selectedFeatures.join(',')}`);
}
Expand Down
31 changes: 31 additions & 0 deletions src/ui/tui/__tests__/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,37 @@ describe('WizardStore', () => {
}),
);
});

it('setMcpComplete includes mcp_features_selected when installed', () => {
const store = createStore();
store.setMcpComplete(McpOutcome.Installed, ['Cursor'], 'all');
expect(wizardCaptureMock).toHaveBeenCalledWith(
'mcp complete',
expect.objectContaining({ mcp_features_selected: 'all' }),
);

wizardCaptureMock.mockClear();
store.setMcpComplete(
McpOutcome.Installed,
['Cursor'],
['dashboards', 'insights'],
);
expect(wizardCaptureMock).toHaveBeenCalledWith(
'mcp complete',
expect.objectContaining({
mcp_features_selected: ['dashboards', 'insights'],
}),
);
});

it('setMcpComplete omits mcp_features_selected when not installed', () => {
const store = createStore();
store.setMcpComplete(McpOutcome.Skipped, [], 'all');
const call = wizardCaptureMock.mock.calls.find(
([event]) => event === 'mcp complete',
);
expect(call?.[1]).not.toHaveProperty('mcp_features_selected');
});
});

// ── Screen resolution (derived state) ────────────────────────────
Expand Down
Loading
Loading