mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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'] }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user