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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ Codex can keep a shell command running after a turn continues. AIR clients can s

See [docs/async-tasks.md](docs/async-tasks.md) for the capability, lifecycle events, and stop request.

### Client-only MCP servers

By default, a session gets the MCP servers in the user's Codex configuration plus the client's `mcpServers`. A client that needs a session to have exactly its own servers sets `strictMcpConfig` in the request's `_meta`, on `session/new`, `session/load`, `session/resume` and `session/fork`:

```json
{
"cwd": "/path/to/project",
"mcpServers": [],
"_meta": { "codex": { "strictMcpConfig": true } }
}
```

The adapter then disables every MCP server that Codex's configuration would load for `cwd`, and turns off the `apps`, `plugins` and `skill_mcp_dependency_install` features, which add MCP servers of their own. The rest of the configuration still applies. A client server whose name matches a configured one is rejected with `invalid_params`, because Codex would merge the two entries. Pass it under another name.

## License

By contributing, you agree that your contributions will be licensed under the Apache 2.0 License.
96 changes: 87 additions & 9 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ export class CodexAcpClient {

const response = await this.resumeThread({
excludeTurns: true,
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? [], readStrictMcpConfig(request._meta)),
cwd: request.cwd,
modelProvider: await this.getResumeModelProvider(),
threadId: request.sessionId,
Expand All @@ -609,11 +609,12 @@ export class CodexAcpClient {

async forkSession(request: acp.ForkSessionRequest): Promise<SessionMetadata> {
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
const strictMcpConfig = readStrictMcpConfig(request._meta);
return await runForkSession(request, additionalDirectories, {
codexClient: this.codexClient,
refreshSkills: (cwd, directories) => this.refreshSkills(cwd, directories),
createSessionConfig: (cwd, directories, mcpServers) =>
this.createSessionConfig(cwd, directories, mcpServers),
this.createSessionConfig(cwd, directories, mcpServers, strictMcpConfig),
getResumeModelProvider: () => this.getResumeModelProvider(),
fetchAvailableModels: () => this.fetchAvailableModels(),
createCurrentModelId: (models, model, reasoningEffort) =>
Expand All @@ -628,7 +629,7 @@ export class CodexAcpClient {

const response = await this.resumeThread({
excludeTurns: true,
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? [], readStrictMcpConfig(request._meta)),
cwd: request.cwd,
modelProvider: await this.getResumeModelProvider(),
threadId: request.sessionId,
Expand Down Expand Up @@ -669,7 +670,7 @@ export class CodexAcpClient {
await this.refreshSkills(request.cwd, additionalDirectories);

const response = await this.codexClient.threadStart({
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers),
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers, readStrictMcpConfig(request._meta)),
modelProvider: this.getModelProvider(),
cwd: request.cwd,
});
Expand Down Expand Up @@ -807,7 +808,8 @@ export class CodexAcpClient {
private async createSessionConfig(
projectPath: string,
additionalDirectories: string[],
mcpServers: Array<McpServer>
mcpServers: Array<McpServer>,
strictMcpConfig: boolean,
): Promise<JsonObject> {
const sessionRoots = [projectPath, ...additionalDirectories];
const activeProvider = this.gatewayConfig
Expand All @@ -830,14 +832,19 @@ export class CodexAcpClient {
}])),
};
const configWithWorkspaceRoots = mergeSandboxWorkspaceWriteRoots(mergedConfig, additionalDirectories);
if (mcpServers.length === 0) {
return configWithWorkspaceRoots;
}

const requestedServers = mcpServers.map(mcp => ({
name: sanitizeMcpServerName(mcp.name),
server: mcp,
}));

if (strictMcpConfig) {
return await this.createStrictMcpSessionConfig(projectPath, configWithWorkspaceRoots, requestedServers);
}

if (mcpServers.length === 0) {
return configWithWorkspaceRoots;
}

let serversToConfigure = requestedServers;
if (shouldDeduplicateMcpConflicts()) {
// Prevents Codex from deep-merging incompatible field types, such as url and stdio schemas.
Expand All @@ -854,6 +861,54 @@ export class CodexAcpClient {
};
}

/**
* Session config for `_meta.codex.strictMcpConfig`: the session's MCP servers are
* exactly the client's. Every server Codex has configured is disabled by name, and
* the features that add MCP servers of their own are turned off.
*/
private async createStrictMcpSessionConfig(
projectPath: string,
config: JsonObject,
requestedServers: Array<{ name: string, server: McpServer }>,
): Promise<JsonObject> {
const configuredNames = await this.getEffectiveMcpServerNames(projectPath);
// A client server cannot replace a configured one: Codex deep-merges the two
// entries, so fields of the configured server would leak into the client's.
const conflicts = requestedServers
.map(mcp => mcp.name)
.filter(name => configuredNames.has(name));
if (conflicts.length > 0) {
throw RequestError.invalidParams(
undefined,
`_meta.codex.strictMcpConfig: MCP server name(s) ${conflicts.join(", ")} are also configured in Codex; pass them under different names`,
);
}

const features = isJsonObject(config["features"]) ? config["features"] : {};
return {
...config,
features: {
...features,
...STRICT_MCP_DISABLED_FEATURES,
},
"mcp_servers": {
...Object.fromEntries([...configuredNames].map(name => [name, {enabled: false}])),
...Object.fromEntries(requestedServers.map(mcp => [mcp.name, this.createMcpSeverConfig(mcp.server)])),
},
};
}

/**
* Names of the MCP servers Codex will load for `projectPath`: the effective config
* only. Servers in disabled layers (an untrusted project, for example) never load,
* and naming them in the session config would create entries with no transport.
*/
private async getEffectiveMcpServerNames(projectPath: string): Promise<Set<string>> {
const response = await this.codexClient.configRead({ includeLayers: false, cwd: projectPath });
const effectiveMcpServers = response?.config?.["mcp_servers"];
return new Set(isJsonObject(effectiveMcpServers) ? Object.keys(effectiveMcpServers) : []);
}

private async getConfigMcpServerNames(projectPath: string): Promise<Set<string>> {
const response = await this.codexClient.configRead({ includeLayers: true, cwd: projectPath });
const effectiveMcpServers = response?.config?.["mcp_servers"];
Expand Down Expand Up @@ -1314,6 +1369,29 @@ interface GatewayConfig {
}
}

/**
* Features that add MCP servers of their own. `strictMcpConfig` turns them off so a
* session's servers are exactly the ones the client passed.
*/
const STRICT_MCP_DISABLED_FEATURES = {
apps: false,
plugins: false,
skill_mcp_dependency_install: false,
} as const;

/** Reads `_meta.codex.strictMcpConfig`. Absent means false; any non-boolean is rejected. */
function readStrictMcpConfig(meta?: Record<string, unknown> | null): boolean {
const codexMeta = meta?.["codex"] as JsonValue | undefined;
if (!isJsonObject(codexMeta) || !("strictMcpConfig" in codexMeta)) {
return false;
}
const value = codexMeta["strictMcpConfig"];
if (typeof value !== "boolean") {
throw RequestError.invalidParams(undefined, "_meta.codex.strictMcpConfig must be a boolean");
}
return value;
}

function readMetaAdditionalRoots(meta?: Record<string, unknown> | null): string[] | undefined {
const rawRoots = meta?.["additionalRoots"];
if (!Array.isArray(rawRoots)) {
Expand Down
69 changes: 69 additions & 0 deletions src/__tests__/CodexACPAgent/mcp-config-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,75 @@ url = "https://example.com/mcp"
})).resolves.toBeDefined();
});

const clientMcp: McpServerStdio = {
name: "client-mcp",
command: path.resolve("node_modules/.bin/mcp-hello-world"),
args: [],
env: [],
};

/** MCP servers the session will run: every server its thread reports, less the disabled ones. */
async function sessionMcpServerNames(meta?: Record<string, unknown>): Promise<string[]> {
const codexAcpAgent = fixture.getCodexAcpAgent();
const newSessionResponse = await codexAcpAgent.newSession({
cwd: projectPath,
mcpServers: [clientMcp],
...(meta && {_meta: meta}),
});
const status = await fixture.getCodexAcpClient().appServerClient.listMcpServerStatus({
threadId: newSessionResponse.sessionId,
});
return status.data
.filter(server => server.runtimeStatus !== "disabled")
.map(server => server.name)
.sort();
}

it('should give a strictMcpConfig session only the ACP MCP servers', async () => {
const codexAcpAgent = fixture.getCodexAcpAgent();
await codexAcpAgent.initialize({protocolVersion: 1});
fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false);

expect(await sessionMcpServerNames()).toEqual(["client-mcp", "shared-mcp"]);
expect(await sessionMcpServerNames({codex: {strictMcpConfig: true}})).toEqual(["client-mcp"]);
});

it('should disable a trusted project MCP server in a strictMcpConfig session', async () => {
fs.appendFileSync(
path.join(codexHome, "config.toml"),
`\n[projects.${JSON.stringify(fs.realpathSync(projectPath))}]\ntrust_level = "trusted"\n`,
"utf8",
);
const codexAcpAgent = fixture.getCodexAcpAgent();
await codexAcpAgent.initialize({protocolVersion: 1});
fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false);

expect(await sessionMcpServerNames()).toEqual(["client-mcp", "project-mcp", "shared-mcp"]);
expect(await sessionMcpServerNames({codex: {strictMcpConfig: true}})).toEqual(["client-mcp"]);
});

it('should reject a strictMcpConfig session whose ACP MCP server shares a configured name', async () => {
const codexAcpAgent = fixture.getCodexAcpAgent();
await codexAcpAgent.initialize({protocolVersion: 1});
fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false);

const conflictingMcp: McpServerStdio = {
name: "shared-mcp",
command: "./node_modules/.bin/mcp-hello-world",
args: [],
env: [],
};

await expect(codexAcpAgent.newSession({
cwd: projectPath,
mcpServers: [conflictingMcp],
_meta: {codex: {strictMcpConfig: true}},
})).rejects.toMatchObject({
code: -32602,
message: expect.stringContaining("shared-mcp"),
});
});

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
Loading