feat(codex): support user-configured MCP servers in remote sessions (#1789)

* feat(codex): load user MCP servers in HAPI sessions

* feat(codex): proxy Windows MCP command shims

* fix(codex): address MCP review feedback

* fix(codex): use HAPI-owned MCP proxy

* fix(codex): preserve Windows MCP argv boundaries

* fix(codex): keep remote MCP placement unchanged
This commit is contained in:
Ananovo
2026-09-09 09:26:43 +08:00
committed by GitHub
parent 66ea35963e
commit 5c5c8b3a9f
18 changed files with 941 additions and 13 deletions
@@ -102,6 +102,40 @@ describe('appServerConfig', () => {
});
});
it('preserves user MCP transport fields when building thread config', () => {
const params = buildThreadStartParams({
cwd: '/workspace/project',
mode: { permissionMode: 'default', collaborationMode: 'default' },
mcpServers: {
'package-manager': {
command: 'uvx',
args: ['example-mcp', 'serve'],
env: { EXAMPLE_TOKEN: 'from-process-environment' }
},
remote: {
url: 'https://example.test/mcp',
bearer_token_env_var: 'REMOTE_MCP_TOKEN'
},
hapi: {
command: 'node',
args: ['mcp']
}
}
});
expect(params.config).toMatchObject({
'mcp_servers.package-manager': {
command: 'uvx',
args: ['example-mcp', 'serve'],
env: { EXAMPLE_TOKEN: 'from-process-environment' }
},
'mcp_servers.remote': {
url: 'https://example.test/mcp',
bearer_token_env_var: 'REMOTE_MCP_TOKEN'
}
});
});
it('ignores CLI overrides when permission mode is not default', () => {
const params = buildThreadStartParams({
cwd: '/workspace/project',
+4 -8
View File
@@ -1,6 +1,6 @@
import type { EnhancedMode } from '../loop';
import type { CodexCliOverrides } from './codexCliOverrides';
import type { McpServersConfig } from './buildHapiMcpBridge';
import type { CodexMcpServersConfig } from './codexMcpServers';
import { getCodexSystemPrompt } from './systemPrompt';
import type {
ApprovalPolicy,
@@ -102,15 +102,11 @@ export function supportsReasoningSummary(model: string | undefined): boolean {
return !MODELS_WITHOUT_REASONING_SUMMARY.has(modelName);
}
function buildMcpServerConfig(mcpServers: McpServersConfig): Record<string, unknown> {
function buildMcpServerConfig(mcpServers: CodexMcpServersConfig): Record<string, unknown> {
const config: Record<string, unknown> = {};
for (const [name, server] of Object.entries(mcpServers)) {
config[`mcp_servers.${name}`] = {
command: server.command,
args: server.args,
...(server.tools ? { tools: server.tools } : {})
};
config[`mcp_servers.${name}`] = { ...server };
}
return config;
@@ -197,7 +193,7 @@ export function buildUserInputFromMessage(
export function buildThreadStartParams(args: {
cwd: string;
mode: EnhancedMode;
mcpServers: McpServersConfig;
mcpServers: CodexMcpServersConfig;
cliOverrides?: CodexCliOverrides;
baseInstructions?: string;
developerInstructions?: string;
+2 -1
View File
@@ -9,6 +9,7 @@ import { startHappyServer } from '@/claude/utils/startHappyServer';
import { getHappyCliCommand } from '@/utils/spawnHappyCLI';
import type { ApiSessionClient } from '@/api/apiSession';
import { exportHapiSessionEnv } from '@/agent/hapiSessionEnv';
import type { CodexMcpServerConfig } from './codexMcpServers';
/**
* MCP server entry configuration.
@@ -19,7 +20,7 @@ export interface McpServerToolConfig {
approval_mode?: McpToolApprovalMode;
}
export interface McpServerEntry {
export interface McpServerEntry extends CodexMcpServerConfig {
command: string;
args: string[];
tools?: Record<string, McpServerToolConfig>;
+43
View File
@@ -0,0 +1,43 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { copyCodexConfigFile, resolveCodexHome } from './codexHome';
const temporaryDirectories: string[] = [];
async function createTemporaryDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'hapi-codex-home-test-'));
temporaryDirectories.push(directory);
return directory;
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe('codexHome', () => {
it('uses CODEX_HOME when configured and trims surrounding whitespace', () => {
expect(resolveCodexHome({ CODEX_HOME: ' C:\\codex-home ' })).toBe('C:\\codex-home');
});
it('copies config.toml without copying authentication or other state', async () => {
const sourceHome = await createTemporaryDirectory();
const targetHome = await createTemporaryDirectory();
await writeFile(join(sourceHome, 'config.toml'), '[mcp_servers.example]\ncommand = "server"\n');
await writeFile(join(sourceHome, 'auth.json'), '{"access_token":"not-copied"}\n');
await expect(copyCodexConfigFile(sourceHome, targetHome))
.resolves.toBe(join(targetHome, 'config.toml'));
await expect(readFile(join(targetHome, 'config.toml'), 'utf8'))
.resolves.toBe('[mcp_servers.example]\ncommand = "server"\n');
await expect(readFile(join(targetHome, 'auth.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
});
it('treats a missing user config as a valid first-run state', async () => {
const sourceHome = await createTemporaryDirectory();
const targetHome = await createTemporaryDirectory();
await expect(copyCodexConfigFile(sourceHome, targetHome)).resolves.toBeNull();
});
});
+32
View File
@@ -0,0 +1,32 @@
import { copyFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
export function resolveCodexHome(env: NodeJS.ProcessEnv = process.env): string {
const configuredHome = env.CODEX_HOME?.trim();
return configuredHome ? configuredHome : join(homedir(), '.codex');
}
/**
* Copy only Codex's user config into a runner-owned home.
*
* Runner-spawned Codex sessions use a temporary CODEX_HOME for token auth.
* Copying config.toml preserves user MCP settings without copying auth files
* or unrelated state. A missing source config is a valid first-run state.
*/
export async function copyCodexConfigFile(sourceHome: string, targetHome: string): Promise<string | null> {
if (resolve(sourceHome) === resolve(targetHome)) {
return null;
}
const targetConfigPath = join(targetHome, 'config.toml');
try {
await copyFile(join(sourceHome, 'config.toml'), targetConfigPath);
return targetConfigPath;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw error;
}
}
+187
View File
@@ -0,0 +1,187 @@
import { existsSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { getHappyCliCommand } from '@/utils/spawnHappyCLI';
import {
prepareCodexMcpServers,
shouldProxyCodexMcpStdio
} from './codexMcpProxy';
const windowsIt = process.platform === 'win32' ? it : it.skip;
describe('codexMcpProxy', () => {
it('limits the compatibility proxy to Windows command shims', () => {
expect(shouldProxyCodexMcpStdio('uvx', 'win32')).toBe(true);
expect(shouldProxyCodexMcpStdio('C:\\Tools\\uvx.exe', 'win32')).toBe(true);
expect(shouldProxyCodexMcpStdio('C:\\Tools\\server.cmd', 'win32')).toBe(true);
expect(shouldProxyCodexMcpStdio('node', 'win32')).toBe(false);
expect(shouldProxyCodexMcpStdio('uvx', 'linux')).toBe(false);
});
it('preserves server fields while replacing only the Windows shim launcher', async () => {
const prepared = await prepareCodexMcpServers({
'package-manager': {
command: 'uvx',
args: ['--from', 'example-mcp==1.0.0', 'example-mcp', 'serve'],
env_vars: ['EXAMPLE_TOKEN'],
enabled: true,
tool_timeout_sec: 60
},
nodeServer: {
command: 'node',
args: ['server.js'],
enabled: true
},
remoteShim: {
command: 'uvx',
args: ['remote-mcp', 'serve'],
environment_id: 'remote',
enabled: true
},
remote: {
url: 'https://example.test/mcp',
bearer_token_env_var: 'REMOTE_MCP_TOKEN'
}
}, 'win32');
try {
const proxied = prepared.servers['package-manager'] as {
command: string;
args: string[];
[key: string]: unknown;
};
expect(proxied).toEqual(expect.objectContaining({
env_vars: ['EXAMPLE_TOKEN'],
enabled: true,
tool_timeout_sec: 60
}));
const specPath = proxied.args.at(-1);
expect(typeof specPath).toBe('string');
if (!specPath) {
throw new Error('Expected a proxy spec path');
}
expect(proxied.args).toContain('mcp-proxy');
expect(proxied.command).toBe(getHappyCliCommand(['mcp-proxy', '--spec', specPath]).command);
expect(existsSync(specPath)).toBe(true);
await expect(readFile(specPath, 'utf8')).resolves.toBe(JSON.stringify({
command: 'uvx',
args: ['--from', 'example-mcp==1.0.0', 'example-mcp', 'serve']
}));
expect(prepared.servers.nodeServer).toEqual({
command: 'node',
args: ['server.js'],
enabled: true
});
expect(prepared.servers.remoteShim).toEqual({
command: 'uvx',
args: ['remote-mcp', 'serve'],
environment_id: 'remote',
enabled: true
});
expect(prepared.servers.remote).toEqual({
url: 'https://example.test/mcp',
bearer_token_env_var: 'REMOTE_MCP_TOKEN'
});
} finally {
await prepared.cleanup();
}
const cleanedProxy = prepared.servers['package-manager'] as { args: string[] };
expect(existsSync(cleanedProxy.args.at(-1) as string)).toBe(false);
await prepared.cleanup();
});
windowsIt('launches a bare command through a temporary Windows shim', async () => {
const directory = await mkdtemp(join(tmpdir(), 'hapi-codex-mcp-shim-test-'));
const serverPath = join(directory, 'server.js');
const shimPath = join(directory, 'example-mcp.cmd');
const specPath = join(directory, 'spec.json');
const originalPath = process.env.PATH ?? '';
const shimArgs = [
'C:\\Users\\Jane Doe\\repo',
'literal&value',
'percent%value',
'caret^value'
];
const serverScript = [
"const receivedArgs = JSON.stringify(process.argv.slice(2));",
"let buffer = '';",
"process.stdin.setEncoding('utf8');",
"process.stdin.on('data', (chunk) => {",
" buffer += chunk;",
" const newline = buffer.indexOf('\\n');",
" if (newline < 0) return;",
" const request = JSON.parse(buffer.slice(0, newline));",
" if (request.method === 'initialize') {",
" process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { protocolVersion: request.params.protocolVersion, capabilities: {}, serverInfo: { name: 'shim-test', version: receivedArgs } } }) + '\\n');",
" }",
"});"
].join('\n');
try {
await writeFile(serverPath, serverScript, 'utf8');
await writeFile(shimPath, '@echo off\r\nnode "%~dp0server.js" %*\r\n', 'utf8');
await writeFile(specPath, JSON.stringify({ command: 'example-mcp', args: shimArgs }), 'utf8');
const proxyCommand = getHappyCliCommand(['mcp-proxy', '--spec', specPath]);
const child = spawn(proxyCommand.command, proxyCommand.args, {
env: { ...process.env, PATH: `${directory};${originalPath}` },
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
});
const response = await new Promise<Record<string, unknown>>((resolve, reject) => {
let output = '';
const timeout = setTimeout(() => {
child.kill();
reject(new Error('Timed out waiting for the Windows MCP shim response'));
}, 5_000);
const finish = (error: Error | null, value?: Record<string, unknown>) => {
clearTimeout(timeout);
if (error) {
reject(error);
} else if (value) {
resolve(value);
}
};
child.once('error', (error) => finish(error));
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
output += chunk;
const newline = output.indexOf('\n');
if (newline < 0) return;
try {
finish(null, JSON.parse(output.slice(0, newline)) as Record<string, unknown>);
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
}
child.kill();
});
child.stderr.resume();
child.stdin.write(JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-06-18' }
}) + '\n');
});
expect(response).toMatchObject({
jsonrpc: '2.0',
id: 1,
result: {
serverInfo: { name: 'shim-test', version: JSON.stringify(shimArgs) }
}
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
+127
View File
@@ -0,0 +1,127 @@
import { randomUUID } from 'node:crypto';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { getHappyCliCommand } from '@/utils/spawnHappyCLI';
import type { CodexMcpServersConfig } from './codexMcpServers';
const WINDOWS_COMMAND_SHIMS = new Set([
'bunx',
'npx',
'npm',
'pnpm',
'uv',
'uvx',
'yarn'
]);
type StdioProxySpec = {
command: string;
args: string[];
cwd?: string;
};
export type PreparedCodexMcpServers = {
servers: CodexMcpServersConfig;
proxiedServerNames: string[];
cleanup: () => Promise<void>;
};
function commandBaseName(command: string): string {
const normalized = command.trim().replace(/[\\/]+$/, '');
const lastSeparator = Math.max(normalized.lastIndexOf('\\'), normalized.lastIndexOf('/'));
return (lastSeparator >= 0 ? normalized.slice(lastSeparator + 1) : normalized).toLowerCase();
}
/**
* Codex currently launches Windows MCP commands verbatim. Package-manager
* shims can close before returning the initialize response when launched by
* the Rust stdio launcher, while the same process works through a native
* HAPI child process. Keep the workaround narrow to known command shims.
*/
export function shouldProxyCodexMcpStdio(command: string, platform = process.platform): boolean {
if (platform !== 'win32') {
return false;
}
const baseName = commandBaseName(command);
const executableName = baseName.endsWith('.exe') ? baseName.slice(0, -4) : baseName;
return WINDOWS_COMMAND_SHIMS.has(baseName)
|| WINDOWS_COMMAND_SHIMS.has(executableName)
|| baseName.endsWith('.cmd')
|| baseName.endsWith('.bat');
}
function runsOnRemoteEnvironment(server: Record<string, unknown>): boolean {
return server.experimental_environment === 'remote'
|| (typeof server.environment_id === 'string' && server.environment_id !== 'local');
}
async function writeProxySpec(spec: StdioProxySpec): Promise<{ directory: string; path: string }> {
const directory = await mkdtemp(join(tmpdir(), 'hapi-codex-mcp-'));
const path = join(directory, `${randomUUID()}.json`);
await writeFile(path, JSON.stringify(spec), { encoding: 'utf8', mode: 0o600 });
return { directory, path };
}
/**
* Prepare user MCP entries for a Codex session.
*
* Only Windows package-manager shims are proxied. All other entries, URL
* transports, environment fields, and newer Codex fields remain unchanged.
*/
export async function prepareCodexMcpServers(
servers: CodexMcpServersConfig,
platform = process.platform
): Promise<PreparedCodexMcpServers> {
const prepared: CodexMcpServersConfig = {};
const proxiedServerNames: string[] = [];
const temporaryDirectories: string[] = [];
try {
for (const [name, server] of Object.entries(servers)) {
if (
typeof server.command !== 'string'
|| runsOnRemoteEnvironment(server)
|| !shouldProxyCodexMcpStdio(server.command, platform)
) {
prepared[name] = server;
continue;
}
const spec = await writeProxySpec({
command: server.command,
args: Array.isArray(server.args)
? server.args.filter((arg): arg is string => typeof arg === 'string')
: [],
...(typeof server.cwd === 'string' && server.cwd.trim().length > 0
? { cwd: server.cwd }
: {})
});
temporaryDirectories.push(spec.directory);
const proxyCommand = getHappyCliCommand(['mcp-proxy', '--spec', spec.path]);
prepared[name] = {
...server,
command: proxyCommand.command,
args: proxyCommand.args
};
proxiedServerNames.push(name);
}
} catch (error) {
await Promise.all(temporaryDirectories.map((directory) => rm(directory, { force: true, recursive: true })));
throw error;
}
let cleaned = false;
const cleanup = async () => {
if (cleaned) {
return;
}
cleaned = true;
await Promise.all(temporaryDirectories.map((directory) => rm(directory, { force: true, recursive: true })));
};
return { servers: prepared, proxiedServerNames, cleanup };
}
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import {
extractCodexMcpServers,
HAPI_MCP_SERVER_NAME,
mergeCodexMcpServers
} from './codexMcpServers';
describe('codexMcpServers', () => {
it('extracts stdio and HTTP servers while preserving transport-specific fields', () => {
const result = extractCodexMcpServers({
mcp_servers: {
'package-manager': {
command: 'uvx',
args: ['example-mcp', 'serve'],
env: { EXAMPLE_TOKEN: 'from-process-environment' },
environment_id: 'local',
enabled: true,
tool_timeout_sec: 60
},
remote: {
url: 'https://example.test/mcp',
bearer_token_env_var: 'REMOTE_MCP_TOKEN'
}
}
});
expect(result).toEqual({
'package-manager': {
command: 'uvx',
args: ['example-mcp', 'serve'],
env: { EXAMPLE_TOKEN: 'from-process-environment' },
environment_id: 'local',
enabled: true,
tool_timeout_sec: 60
},
remote: {
url: 'https://example.test/mcp',
bearer_token_env_var: 'REMOTE_MCP_TOKEN'
}
});
});
it('returns no servers when the effective config has no MCP table', () => {
expect(extractCodexMcpServers({ model: 'gpt-5' })).toEqual({});
expect(extractCodexMcpServers(null)).toEqual({});
});
it('rejects malformed MCP entries before they reach app-server', () => {
expect(() => extractCodexMcpServers({
mcp_servers: {
broken: {
command: ['not', 'a', 'command']
}
}
})).toThrow('Invalid Codex MCP server configuration for "broken"');
});
it('omits null defaults returned by config/read', () => {
expect(extractCodexMcpServers({
mcp_servers: {
external: {
command: 'server',
args: [],
environment_id: 'local',
enabled: true,
tool_timeout_sec: null,
startup_timeout_sec: undefined
}
}
})).toEqual({
external: {
command: 'server',
args: [],
environment_id: 'local',
enabled: true
}
});
});
it('reserves the HAPI server name and keeps the HAPI bridge on merge', () => {
const merged = mergeCodexMcpServers(
{
[HAPI_MCP_SERVER_NAME]: { command: 'user-hapi', args: [] },
external: { command: 'external', args: [] }
},
{
[HAPI_MCP_SERVER_NAME]: { command: 'hapi', args: ['mcp'] }
}
);
expect(merged).toEqual({
external: { command: 'external', args: [] },
[HAPI_MCP_SERVER_NAME]: { command: 'hapi', args: ['mcp'] }
});
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Helpers for carrying user-configured Codex MCP servers into HAPI sessions.
*
* The app-server reports the effective Codex configuration, so keep external
* entries opaque here. This preserves newer Codex MCP fields without making
* HAPI duplicate Codex's transport schema.
*/
export interface CodexMcpServerConfig {
[key: string]: unknown;
}
export type CodexMcpServersConfig = Record<string, CodexMcpServerConfig>;
export const HAPI_MCP_SERVER_NAME = 'hapi';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isMcpServerConfig(value: unknown): value is CodexMcpServerConfig {
if (!isRecord(value)) {
return false;
}
if (value.command !== undefined && typeof value.command !== 'string') {
return false;
}
if (value.args !== undefined && (
!Array.isArray(value.args)
|| value.args.some((arg) => typeof arg !== 'string')
)) {
return false;
}
if (value.url !== undefined && typeof value.url !== 'string') {
return false;
}
return typeof value.command === 'string' || typeof value.url === 'string';
}
function omitNullishFields(value: CodexMcpServerConfig): CodexMcpServerConfig {
return Object.fromEntries(
Object.entries(value).filter(([, entryValue]) => entryValue !== null && entryValue !== undefined)
);
}
/**
* Extract the MCP server table from a Codex config/read response.
*
* `hapi` is intentionally excluded: that name is reserved for the bridge
* HAPI injects into every Codex session.
*/
export function extractCodexMcpServers(config: unknown): CodexMcpServersConfig {
if (!isRecord(config) || !isRecord(config.mcp_servers)) {
return {};
}
const servers: CodexMcpServersConfig = {};
for (const [name, value] of Object.entries(config.mcp_servers)) {
if (name === HAPI_MCP_SERVER_NAME) {
continue;
}
if (!isMcpServerConfig(value)) {
throw new Error(`Invalid Codex MCP server configuration for "${name}"`);
}
servers[name] = omitNullishFields(value);
}
return servers;
}
/**
* Merge user entries with HAPI's bridge configuration.
*
* HAPI wins on the reserved name, while all other user entries remain opaque
* so Codex can handle the transport-specific fields it understands.
*/
export function mergeCodexMcpServers(
userMcpServers: CodexMcpServersConfig,
hapiMcpServers: CodexMcpServersConfig
): CodexMcpServersConfig {
const merged = { ...userMcpServers };
delete merged[HAPI_MCP_SERVER_NAME];
return {
...merged,
...hapiMcpServers
};
}