diff --git a/src/steps/add-mcp-server-to-clients/__tests__/plugin-client.test.ts b/src/steps/add-mcp-server-to-clients/__tests__/plugin-client.test.ts new file mode 100644 index 00000000..832e462e --- /dev/null +++ b/src/steps/add-mcp-server-to-clients/__tests__/plugin-client.test.ts @@ -0,0 +1,27 @@ +import { isExternalClientConfigError } from '../plugin-client'; + +describe('isExternalClientConfigError', () => { + it('matches Claude Code malformed plugin schema errors', () => { + expect( + isExternalClientConfigError( + 'Invalid schema: plugins.5.source: Invalid input', + ), + ).toBe(true); + expect( + isExternalClientConfigError( + 'Command failed: claude plugin install posthog\n' + + 'Invalid schema: plugins.0.source: Invalid input', + ), + ).toBe(true); + }); + + it('does not match unrelated CLI errors', () => { + expect(isExternalClientConfigError('network timeout')).toBe(false); + expect(isExternalClientConfigError('command not found')).toBe(false); + expect(isExternalClientConfigError('ENOENT')).toBe(false); + }); + + it('handles empty input safely', () => { + expect(isExternalClientConfigError('')).toBe(false); + }); +}); diff --git a/src/steps/add-mcp-server-to-clients/clients/__tests__/claude-code.test.ts b/src/steps/add-mcp-server-to-clients/clients/__tests__/claude-code.test.ts index 1aa2e3b2..4678dd85 100644 --- a/src/steps/add-mcp-server-to-clients/clients/__tests__/claude-code.test.ts +++ b/src/steps/add-mcp-server-to-clients/clients/__tests__/claude-code.test.ts @@ -129,6 +129,21 @@ describe('ClaudeCodeMCPClient — plugin methods', () => { ); }); + it('returns failure without capturing exception when ~/.claude config is malformed', async () => { + execSyncMock.mockImplementation((cmd: string) => { + if (String(cmd).includes('plugin install')) { + throw new Error( + 'Command failed: claude plugin install posthog\n' + + 'Invalid schema: plugins.5.source: Invalid input', + ); + } + return Buffer.from(''); + }); + const client = new ClaudeCodeMCPClient(); + await expect(client.installPlugin()).resolves.toEqual({ success: false }); + expect(analytics.captureException).not.toHaveBeenCalled(); + }); + it('returns failure when no binary is found', async () => { execSyncMock.mockImplementation(() => { throw new Error('not found'); @@ -137,4 +152,39 @@ describe('ClaudeCodeMCPClient — plugin methods', () => { await expect(client.installPlugin()).resolves.toEqual({ success: false }); }); }); + + describe('removeServer', () => { + it('returns success when claude mcp remove exits 0', async () => { + execSyncMock.mockImplementation(() => Buffer.from('')); + const client = new ClaudeCodeMCPClient(); + await expect(client.removeServer()).resolves.toEqual({ success: true }); + }); + + it('returns failure and captures exception on unexpected error', async () => { + execSyncMock.mockImplementation((cmd: string) => { + if (cmd === 'command -v claude') return Buffer.from(''); + throw new Error('network timeout'); + }); + const client = new ClaudeCodeMCPClient(); + await expect(client.removeServer()).resolves.toEqual({ success: false }); + expect(analytics.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('network timeout'), + }), + ); + }); + + it('returns failure without capturing exception when ~/.claude config is malformed', async () => { + execSyncMock.mockImplementation((cmd: string) => { + if (cmd === 'command -v claude') return Buffer.from(''); + throw new Error( + 'Command failed: claude mcp remove --scope user posthog\n' + + 'Invalid schema: plugins.0.source: Invalid input', + ); + }); + const client = new ClaudeCodeMCPClient(); + await expect(client.removeServer()).resolves.toEqual({ success: false }); + expect(analytics.captureException).not.toHaveBeenCalled(); + }); + }); }); 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..237d895d 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 @@ -102,22 +102,32 @@ describe('CodexMCPClient', () => { describe('removeServer', () => { it('invokes the resolved binary with mcp remove and returns success', async () => { - spawnSyncMock.mockReturnValue({ status: 0 }); + spawnSyncMock.mockReturnValue({ status: 0, stderr: '' }); const client = new CodexMCPClient(); await expect(client.removeServer()).resolves.toEqual({ success: true }); expect(spawnSyncMock).toHaveBeenCalledWith( CODEX_PATH, ['mcp', 'remove', 'posthog'], - { stdio: 'ignore' }, + { encoding: 'utf-8' }, ); }); it('returns false and captures exception on failure', async () => { - spawnSyncMock.mockReturnValue({ status: 1 }); + spawnSyncMock.mockReturnValue({ status: 1, stderr: 'network timeout' }); const client = new CodexMCPClient(); await expect(client.removeServer()).resolves.toEqual({ success: false }); expect(analytics.captureException).toHaveBeenCalled(); }); + + it('returns failure without capturing exception when Codex config is malformed', async () => { + spawnSyncMock.mockReturnValue({ + status: 1, + stderr: 'Invalid schema: plugins.2.source: Invalid input', + }); + const client = new CodexMCPClient(); + await expect(client.removeServer()).resolves.toEqual({ success: false }); + expect(analytics.captureException).not.toHaveBeenCalled(); + }); }); describe('supportsPlugin', () => { @@ -175,5 +185,15 @@ describe('CodexMCPClient', () => { }), ); }); + + it('returns failure without capturing exception when Codex config is malformed', async () => { + spawnSyncMock.mockReturnValue({ + status: 1, + stderr: 'Invalid schema: plugins.5.source: Invalid input', + }); + const client = new CodexMCPClient(); + await expect(client.installPlugin()).resolves.toEqual({ success: false }); + expect(analytics.captureException).not.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..990d2399 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,6 +1,10 @@ import { DefaultMCPClient } from '../MCPClient'; import { DefaultMCPClientConfig } from '../defaults'; -import { PluginCapable, PluginInstallResult } from '../plugin-client'; +import { + PluginCapable, + PluginInstallResult, + isExternalClientConfigError, +} from '../plugin-client'; import { z } from 'zod'; import { execSync } from 'child_process'; import { analytics } from '../../../utils/analytics'; @@ -110,12 +114,16 @@ export class ClaudeCodeMCPClient try { execSync(command); } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (isExternalClientConfigError(msg)) { + debug( + ` Claude Code mcp remove skipped: existing ~/.claude config has invalid plugin entries. ` + + `Fix them with 'claude plugin list' and retry.`, + ); + return Promise.resolve({ success: false }); + } analytics.captureException( - new Error( - `Failed to remove server from Claude Code: ${ - error instanceof Error ? error.message : String(error) - }`, - ), + new Error(`Failed to remove server from Claude Code: ${msg}`), ); return Promise.resolve({ success: false }); } @@ -151,6 +159,13 @@ export class ClaudeCodeMCPClient if (msg.includes('already installed') || msg.includes('already exists')) { return Promise.resolve({ success: true, alreadyInstalled: true }); } + if (isExternalClientConfigError(msg)) { + debug( + ` Claude Code plugin install skipped: existing ~/.claude config has invalid plugin entries. ` + + `Fix them with 'claude plugin list' and retry.`, + ); + return Promise.resolve({ success: false }); + } analytics.captureException( new Error(`Claude Code plugin install failed: ${msg}`), ); 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..ce30ff82 100644 --- a/src/steps/add-mcp-server-to-clients/clients/codex.ts +++ b/src/steps/add-mcp-server-to-clients/clients/codex.ts @@ -6,9 +6,14 @@ import * as path from 'node:path'; import { DefaultMCPClient } from '../MCPClient'; import { DefaultMCPClientConfig } from '../defaults'; -import { PluginCapable, PluginInstallResult } from '../plugin-client'; +import { + PluginCapable, + PluginInstallResult, + isExternalClientConfigError, +} from '../plugin-client'; import { analytics } from '../../../utils/analytics'; +import { debug } from '../../../utils/debug'; export const CodexMCPConfig = DefaultMCPClientConfig; @@ -60,12 +65,19 @@ export class CodexMCPClient extends DefaultMCPClient implements PluginCapable { if (!binary) return Promise.resolve({ success: false }); const result = spawnSync(binary, ['mcp', 'remove', 'posthog'], { - stdio: 'ignore', + encoding: 'utf-8', }); if (result.error || result.status !== 0) { + const stderr = result.stderr ?? ''; + if (isExternalClientConfigError(stderr)) { + debug( + `Codex mcp remove skipped: existing Codex config is malformed. ${stderr.trim()}`, + ); + return Promise.resolve({ success: false }); + } analytics.captureException( - new Error('Failed to remove server from Codex CLI.'), + new Error(`Failed to remove server from Codex CLI: ${stderr}`), ); return Promise.resolve({ success: false }); } @@ -122,8 +134,15 @@ export class CodexMCPClient extends DefaultMCPClient implements PluginCapable { } if (result.status !== 0) { + const stderr = result.stderr ?? ''; + if (isExternalClientConfigError(stderr)) { + debug( + `Codex plugin install skipped: existing Codex config is malformed. ${stderr.trim()}`, + ); + return Promise.resolve({ success: false }); + } analytics.captureException( - new Error(`Codex plugin install failed: ${result.stderr ?? ''}`), + new Error(`Codex plugin install failed: ${stderr}`), ); return Promise.resolve({ success: false }); } diff --git a/src/steps/add-mcp-server-to-clients/plugin-client.ts b/src/steps/add-mcp-server-to-clients/plugin-client.ts index 3d05bf7a..89768d25 100644 --- a/src/steps/add-mcp-server-to-clients/plugin-client.ts +++ b/src/steps/add-mcp-server-to-clients/plugin-client.ts @@ -17,3 +17,18 @@ export function isPluginCapable(client: T): client is T & PluginCapable { 'installPlugin' in client ); } + +// Errors emitted by an MCP host CLI (Claude Code, Codex, …) when its own +// pre-existing user config is malformed — i.e. the wizard's command was +// rejected before it could run, because of state outside the wizard's scope. +// We must not capture these as wizard exceptions. +const EXTERNAL_CLIENT_CONFIG_ERROR_PATTERNS: RegExp[] = [ + // Claude Code: malformed plugin entries in ~/.claude config, e.g. + // "Invalid schema: plugins.5.source: Invalid input" + /Invalid schema:[^\n]*plugins\.\d+\./i, +]; + +export function isExternalClientConfigError(message: string): boolean { + if (!message) return false; + return EXTERNAL_CLIENT_CONFIG_ERROR_PATTERNS.some((p) => p.test(message)); +}