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
35 changes: 28 additions & 7 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,9 +758,15 @@ export class CodexAcpClient {
}));
let serversToConfigure = requestedServers;
if (shouldDeduplicateMcpConflicts()) {
// Prevents Codex from deep-merging incompatible field types, such as url and stdio schemas.
const existingNames = await this.getConfigMcpServerNames(projectPath);
serversToConfigure = requestedServers.filter(mcp => !existingNames.has(mcp.name));
// Codex deep-merges session config into its persisted config. Let the session replace
// connection details when both definitions use the same transport, but keep filtering
// incompatible transports because merging e.g. `url` and `command` produces an invalid
// hybrid definition.
const existingServers = await this.getConfigMcpServers(projectPath);
serversToConfigure = requestedServers.filter(mcp => {
const existing = existingServers.get(mcp.name);
return existing === undefined || haveCompatibleMcpTransports(existing, mcp.server);
});
}
if (serversToConfigure.length === 0) {
return configWithWorkspaceRoots;
Expand All @@ -772,18 +778,23 @@ export class CodexAcpClient {
};
}

private async getConfigMcpServerNames(projectPath: string): Promise<Set<string>> {
private async getConfigMcpServers(projectPath: string): Promise<Map<string, JsonObject>> {
const response = await this.codexClient.configRead({ includeLayers: true, cwd: projectPath });
const effectiveMcpServers = response?.config?.["mcp_servers"];
const configLayers = response?.layers ?? [];
const layerMcpServers = configLayers.map(layer => {
return isJsonObject(layer.config) ? layer.config["mcp_servers"] : undefined;
});
const configuredMcpServers = [effectiveMcpServers, ...layerMcpServers].filter(isJsonObject);
if (configuredMcpServers.length === 0) {
return new Set();
const servers = new Map<string, JsonObject>();
for (const configured of configuredMcpServers) {
for (const [name, server] of Object.entries(configured)) {
if (!servers.has(name) && isJsonObject(server)) {
servers.set(name, server);
}
}
}
return new Set(configuredMcpServers.flatMap(server => Object.keys(server)));
return servers;
}

getModelProvider(): string | null {
Expand Down Expand Up @@ -1218,6 +1229,16 @@ function shouldDeduplicateMcpConflicts(): boolean {
return !disabledByEnv;
}

function haveCompatibleMcpTransports(configured: JsonObject, requested: McpServer): boolean {
const configuredTransport = "command" in configured && !("url" in configured)
? "stdio"
: "url" in configured && !("command" in configured)
? "http"
: null;
const requestedTransport = "type" in requested ? requested.type : "stdio";
return configuredTransport === requestedTransport;
}

type WireApi = "responses";

type GatewayConfigSource = "authentication" | "acpProviders";
Expand Down
9 changes: 8 additions & 1 deletion src/__tests__/CodexACPAgent/CodexAcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,7 @@ describe('ACP server test', { timeout: 40_000 }, () => {
} as unknown as acp.NewSessionRequest)).rejects.toThrow("additionalDirectories entries must be strings");
});

it('sanitizes whitespace in ACP MCP server names before adding them to Codex config', async () => {
it('adds same-transport ACP MCP overrides while filtering incompatible conflicts', async () => {
const mockFixture = createCodexMockTestFixture();
const codexAcpClient = mockFixture.getCodexAcpClient();
const codexAppServerClient = mockFixture.getCodexAppServerClient();
Expand All @@ -898,6 +898,13 @@ describe('ACP server test', { timeout: 40_000 }, () => {
shared_mcp: {
url: "https://example.com/mcp",
},
stdio_server_one: {
command: "stale-command",
args: ["stale"],
},
global_only: {
command: "global-command",
},
},
},
} as any);
Expand Down
40 changes: 40 additions & 0 deletions src/__tests__/CodexACPAgent/mcp-config-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,18 @@ describe('MCP config merge across configured MCP servers and ACP request', { tim
beforeEach(() => {
vi.clearAllMocks();

const node = JSON.stringify(process.execPath);
const mcpServer = JSON.stringify(path.resolve(process.cwd(), "node_modules/mcp-hello-world/build/stdio.js"));
const globalConfig = `
[mcp_servers.shared-mcp]
url = "https://example.com/mcp"

[mcp_servers.same-transport]
command = "missing-global-command"

[mcp_servers.global-only]
command = ${node}
args = [${mcpServer}]
`;

const projectConfig = `
Expand Down Expand Up @@ -97,6 +106,37 @@ url = "https://example.com/mcp"
})).resolves.toBeDefined();
});

it('should prefer a same-transport ACP MCP and preserve unrelated configured servers', async () => {
const codexAcpAgent = fixture.getCodexAcpAgent();
await codexAcpAgent.initialize({protocolVersion: 1});
fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false);

const codexAcpClient = fixture.getCodexAcpClient();
const startupVersion = codexAcpClient.getMcpServerStartupVersion();
const mcpServer = path.resolve(process.cwd(), "node_modules/mcp-hello-world/build/stdio.js");
await codexAcpAgent.newSession({
cwd: "",
mcpServers: [{
name: "same-transport",
command: process.execPath,
args: [mcpServer],
env: [],
}],
});

await expect(codexAcpClient.awaitMcpServerStartup(["same-transport"], startupVersion)).resolves.toEqual({
ready: ["same-transport"],
failed: [],
cancelled: [],
});
await expect(codexAcpClient.listMcpServers()).resolves.toMatchObject({
data: expect.arrayContaining([
expect.objectContaining({name: "same-transport"}),
expect.objectContaining({name: "global-only"}),
]),
});
});

it('should not filter the conflicting ACP MCP when config filtering is disabled', async () => {
vi.stubEnv("DISABLE_MCP_CONFIG_FILTERING", "true");
const codexAcpAgent = fixture.getCodexAcpAgent();
Expand Down