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
Expand Up @@ -166,9 +166,19 @@ describe('CodexMCPClient', () => {
});

describe('supportsPlugin', () => {
it('returns true when codex is in PATH', () => {
it('returns true when codex knows the plugin marketplace subcommand', () => {
spawnSyncMock.mockReturnValue({
status: 0,
stdout: 'Usage: codex plugin marketplace <COMMAND>',
stderr: '',
});
const client = new CodexMCPClient();
expect(client.supportsPlugin()).toBe(true);
expect(spawnSyncMock).toHaveBeenCalledWith(
CODEX_PATH,
['plugin', 'marketplace', '--help'],
{ encoding: 'utf-8' },
);
});

it('returns false when codex binary is not found', () => {
Expand All @@ -178,6 +188,29 @@ describe('CodexMCPClient', () => {
const client = new CodexMCPClient();
expect(client.supportsPlugin()).toBe(false);
});

it('returns false when codex is too old to know the marketplace subcommand', () => {
spawnSyncMock.mockReturnValue({
status: 2,
stdout: '',
stderr: "error: unexpected argument 'marketplace' found",
});
const client = new CodexMCPClient();
expect(client.supportsPlugin()).toBe(false);
});

it('caches the feature-detection result across calls', () => {
spawnSyncMock.mockReturnValue({
status: 0,
stdout: 'Usage: codex plugin marketplace <COMMAND>',
stderr: '',
});
const client = new CodexMCPClient();
expect(client.supportsPlugin()).toBe(true);
expect(client.supportsPlugin()).toBe(true);
// Only one probe despite two calls.
expect(spawnSyncMock).toHaveBeenCalledTimes(1);
});
});

describe('installPlugin', () => {
Expand Down Expand Up @@ -219,5 +252,16 @@ describe('CodexMCPClient', () => {
}),
);
});

it('skips cleanly without capturing an exception on older Codex versions', async () => {
spawnSyncMock.mockReturnValue({
status: 2,
stdout: '',
stderr: "error: unexpected argument 'marketplace' found",
});
const client = new CodexMCPClient();
await expect(client.installPlugin()).resolves.toEqual({ success: false });
expect(analytics.captureException).not.toHaveBeenCalled();
});
});
});
43 changes: 42 additions & 1 deletion src/steps/add-mcp-server-to-clients/clients/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type CodexMCPConfig = z.infer<typeof DefaultMCPClientConfig>;
export class CodexMCPClient extends DefaultMCPClient implements PluginCapable {
name = 'Codex';
private codexBinaryPath: string | null = null;
private pluginMarketplaceSupported: boolean | null = null;

constructor() {
super();
Expand Down Expand Up @@ -111,8 +112,40 @@ export class CodexMCPClient extends DefaultMCPClient implements PluginCapable {
return Promise.resolve({ success: true });
}

/**
* Codex CLI versions that predate the `plugin marketplace` subcommand parse
* `plugin` as a prompt and reject the rest of the arguments, e.g.
* `error: unexpected argument 'marketplace' found`. Detect that shape so we
* can treat those versions as "plugin not supported" instead of a hard error.
*/
private isPluginUnsupportedOutput(output: string): boolean {
const lower = output.toLowerCase();
return (
lower.includes('unexpected argument') ||
lower.includes('unrecognized subcommand')
);
}

supportsPlugin(): boolean {
return this.findCodexBinary() !== null;
const binary = this.findCodexBinary();
if (!binary) return false;

if (this.pluginMarketplaceSupported !== null) {
return this.pluginMarketplaceSupported;
}

// Feature-detect the `plugin marketplace` subcommand before we try to use
// it. Old Codex versions don't know it and would fail the install outright.
const result = spawnSync(binary, ['plugin', 'marketplace', '--help'], {
encoding: 'utf-8',
});
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
this.pluginMarketplaceSupported =
result.error == null &&
result.status === 0 &&
!this.isPluginUnsupportedOutput(output);

return this.pluginMarketplaceSupported;
}

isPluginInstalled(): Promise<boolean> {
Expand Down Expand Up @@ -160,6 +193,14 @@ export class CodexMCPClient extends DefaultMCPClient implements PluginCapable {
}

if (result.status !== 0) {
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
// Older Codex versions lack the `plugin marketplace` subcommand and
// reject the arguments (e.g. "unexpected argument 'marketplace' found").
// Skip cleanly rather than surfacing a hard error.
if (this.isPluginUnsupportedOutput(output)) {
this.pluginMarketplaceSupported = false;
return Promise.resolve({ success: false });
}
analytics.captureException(
new Error(`Codex plugin install failed: ${result.stderr ?? ''}`),
);
Expand Down
Loading