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 index 543bfc2f..d0e02336 100644 --- 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 @@ -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(); }); }); diff --git a/src/steps/add-mcp-server-to-clients/clients/claude-code.ts b/src/steps/add-mcp-server-to-clients/clients/claude-code.ts index 19c88b92..8a3d7c64 100644 --- a/src/steps/add-mcp-server-to-clients/clients/claude-code.ts +++ b/src/steps/add-mcp-server-to-clients/clients/claude-code.ts @@ -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'; @@ -85,17 +85,63 @@ export class ClaudeCodeMCPClient } } - isServerInstalled(): Promise { - return this.isPluginInstalled(); + isServerInstalled(local?: boolean): Promise { + 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 { 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 }> { 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 937e75b1..cf5f1285 100644 --- a/src/steps/add-mcp-server-to-clients/clients/codex.ts +++ b/src/steps/add-mcp-server-to-clients/clients/codex.ts @@ -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'; @@ -46,13 +46,45 @@ export class CodexMCPClient extends DefaultMCPClient implements PluginCapable { throw new Error('Not implemented'); } - isServerInstalled(): Promise { - return this.isPluginInstalled(); + isServerInstalled(local?: boolean): Promise { + 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 }> { diff --git a/src/steps/add-mcp-server-to-clients/defaults.ts b/src/steps/add-mcp-server-to-clients/defaults.ts index 7f274854..28458f30 100644 --- a/src/steps/add-mcp-server-to-clients/defaults.ts +++ b/src/steps/add-mcp-server-to-clients/defaults.ts @@ -61,6 +61,21 @@ 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': [ { @@ -68,11 +83,6 @@ export const AVAILABLE_FEATURES = { label: 'LLM Analytics', hint: 'LLM usage and cost tracking', }, - { - value: 'prompts', - label: 'Prompts', - hint: 'LLM prompt management', - }, ], 'Development Tools': [ { @@ -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': [ { @@ -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': [ { @@ -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(',')}`); } diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index 9e262131..580c7f0a 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -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) ──────────────────────────── diff --git a/src/ui/tui/screens/McpScreen.tsx b/src/ui/tui/screens/McpScreen.tsx index f536cf55..bf805ec4 100644 --- a/src/ui/tui/screens/McpScreen.tsx +++ b/src/ui/tui/screens/McpScreen.tsx @@ -25,6 +25,7 @@ import type { McpInstaller, McpClientInfo } from '../services/mcp-installer.js'; import { AVAILABLE_FEATURES, ALL_FEATURE_VALUES, + isAllFeaturesSelected, } from '../../../steps/add-mcp-server-to-clients/defaults.js'; export type McpMode = 'install' | 'remove'; @@ -49,10 +50,14 @@ const markDone = ( store: WizardStore, outcome: McpOutcome, clients: string[] = [], + featuresSelected?: 'all' | string[], ) => { - store.setMcpComplete(outcome, clients); + store.setMcpComplete(outcome, clients, featuresSelected); }; +const reportFeatures = (features: string[]): 'all' | string[] => + isAllFeaturesSelected(features) ? 'all' : features; + export const McpScreen = ({ store, installer, @@ -74,6 +79,7 @@ export const McpScreen = ({ const [selectedClientNames, setSelectedClientNames] = useState([]); const [resultClients, setResultClients] = useState([]); const [pluginClients, setPluginClients] = useState([]); + const [installMode, setInstallMode] = useState<'all' | 'custom'>('custom'); useEffect(() => { void (async () => { @@ -93,10 +99,14 @@ export const McpScreen = ({ })(); }, [installer]); // eslint-disable-line - const proceedToFeatureSelectOrInstall = (clientNames: string[]) => { + const proceedAfterClientPick = ( + clientNames: string[], + chosenMode: 'all' | 'custom', + ) => { setSelectedClientNames(clientNames); - // Skip feature picker if CLI already specified features - if (store.session.mcpFeatures) { + if (chosenMode === 'all') { + void doInstall(clientNames, [...ALL_FEATURE_VALUES]); + } else if (store.session.mcpFeatures) { void doInstall(clientNames, store.session.mcpFeatures); } else { setPhase(Phase.FeatureSelect); @@ -107,7 +117,20 @@ export const McpScreen = ({ if (isRemove) { void doRemove(); } else if (clients.length === 1) { - proceedToFeatureSelectOrInstall(clients.map((c) => c.name)); + proceedAfterClientPick([clients[0]!.name], 'custom'); + } else { + setPhase(Phase.Pick); + } + }; + + const handleTriStateChoice = (choice: 'all' | 'custom' | 'skip') => { + if (choice === 'skip') { + handleSkip(); + return; + } + setInstallMode(choice); + if (clients.length === 1) { + proceedAfterClientPick([clients[0]!.name], choice); } else { setPhase(Phase.Pick); } @@ -121,26 +144,60 @@ export const McpScreen = ({ setPhase(Phase.Working); let mcpResult: string[] = []; let pluginResult: string[] = []; - try { - mcpResult = await installer.install( - names, - features, - store.session.apiKey, - ); - } catch { - // mcpResult stays [] - } - try { - pluginResult = await installer.installPlugins(names); - } catch { - // best-effort — plugin failure does not affect MCP outcome + + const pluginCapableSet = new Set( + clients.filter((c) => c.supportsPlugin).map((c) => c.name), + ); + const pluginCapableNames = names.filter((n) => pluginCapableSet.has(n)); + const directNames = names.filter((n) => !pluginCapableSet.has(n)); + + if (installMode === 'all') { + // Plugin-capable clients get the plugin (which bundles MCP). + // Non-plugin-capable clients get a direct MCP config write. + try { + mcpResult = await installer.install( + directNames, + features, + store.session.apiKey, + ); + } catch { + // mcpResult stays [] + } + try { + pluginResult = await installer.installPlugins(pluginCapableNames); + } catch { + // best-effort + } + } else { + // 'custom' — MCP-only for every selected client. Plugin install is + // skipped so the user's feature selection is actually respected. + try { + mcpResult = await installer.install( + names, + features, + store.session.apiKey, + ); + } catch { + // mcpResult stays [] + } } + setResultClients(mcpResult); setPluginClients(pluginResult); setPhase(Phase.Done); - const outcome = - mcpResult.length > 0 ? McpOutcome.Installed : McpOutcome.Failed; - setTimeout(() => markDone(store, outcome, mcpResult), 2000); + const succeeded = mcpResult.length + pluginResult.length > 0; + const outcome = succeeded ? McpOutcome.Installed : McpOutcome.Failed; + const featuresReport = reportFeatures(features ?? [...ALL_FEATURE_VALUES]); + setTimeout( + () => + markDone( + store, + outcome, + [...mcpResult, ...pluginResult], + featuresReport, + ), + 2000, + ); }; const doRemove = async () => { @@ -182,32 +239,67 @@ export const McpScreen = ({ Detected: {clients.map((c) => c.name).join(', ')} - c.supportsPlugin) ? ' and plugin' : '' - }?`} - confirmLabel={isRemove ? 'Remove' : 'Install'} - cancelLabel="No thanks" - onConfirm={handleConfirm} - onCancel={handleSkip} - /> + {!isRemove && !store.session.mcpFeatures ? ( + c.supportsPlugin) ? ' and plugin' : '' + }?`} + options={[ + { + label: 'Install with all features', + value: 'all', + hint: 'recommended', + }, + { + label: 'Customize features', + value: 'custom', + hint: 'MCP only', + }, + { label: 'No thanks', value: 'skip' }, + ]} + mode="single" + onSelect={(choice) => + handleTriStateChoice(choice as 'all' | 'custom' | 'skip') + } + /> + ) : ( + c.supportsPlugin) ? ' and plugin' : '' + }?`} + confirmLabel={isRemove ? 'Remove' : 'Install'} + cancelLabel="No thanks" + onConfirm={handleConfirm} + onCancel={handleSkip} + /> + )} )} {phase === Phase.Pick && ( ({ label: c.name, value: c.name, + hint: + installMode === 'all' + ? c.supportsPlugin + ? 'plugin' + : 'MCP' + : undefined, }))} mode="multi" onSelect={(selected) => { const names = Array.isArray(selected) ? selected : [selected]; - proceedToFeatureSelectOrInstall(names); + proceedAfterClientPick(names, installMode); }} /> )} @@ -216,7 +308,7 @@ export const McpScreen = ({ { void doInstall(selectedClientNames, features); }} @@ -231,19 +323,35 @@ export const McpScreen = ({ {phase === Phase.Done && ( - {resultClients.length > 0 ? ( + {resultClients.length + pluginClients.length > 0 ? ( <> - - {'\u2714'} MCP server - {!isRemove && pluginClients.length > 0 ? ' and plugin' : ''}{' '} - {isRemove ? 'removed from' : 'installed for'}: - - {resultClients.map((name, i) => ( - - {' '} - {'\u2022'} {name} - - ))} + {pluginClients.length > 0 && ( + <> + + {'\u2714'} Plugin installed for: + + {pluginClients.map((name, i) => ( + + {' '} + {'\u2022'} {name} + + ))} + + )} + {resultClients.length > 0 && ( + <> + + {'\u2714'} MCP server{' '} + {isRemove ? 'removed from' : 'installed for'}: + + {resultClients.map((name, i) => ( + + {' '} + {'\u2022'} {name} + + ))} + + )} ) : ( diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index cf5b0550..d3d589c7 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -408,13 +408,19 @@ export class WizardStore { setMcpComplete( outcome: McpOutcome = McpOutcome.Skipped, installedClients: string[] = [], + featuresSelected?: 'all' | string[], ): void { this.$session.setKey('mcpComplete', true); this.$session.setKey('mcpOutcome', outcome); this.$session.setKey('mcpInstalledClients', installedClients); + const featuresPayload = + outcome === McpOutcome.Installed && featuresSelected !== undefined + ? { mcp_features_selected: featuresSelected } + : {}; analytics.wizardCapture('mcp complete', { mcp_outcome: outcome, mcp_installed_clients: installedClients, + ...featuresPayload, ...sessionProperties(this.session), }); this.emitChange();