Skip to content
Draft
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
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
});
27 changes: 21 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,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';
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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}`),
);
Expand Down
27 changes: 23 additions & 4 deletions src/steps/add-mcp-server-to-clients/clients/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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 });
}
Expand Down
15 changes: 15 additions & 0 deletions src/steps/add-mcp-server-to-clients/plugin-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,18 @@ export function isPluginCapable<T>(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));
}
Loading