Repository Public
1/**2 * Pure helpers for the MCP IPC of the Codex profile: `codex mcp list --json` parsing and argv3 * builders. No I/O. Same secret rules as the Claude helpers: env VALUES, header values, URL4 * credentials and query strings never leave this module.5 *6 * Observed CLI formats (codex-cli 0.166.0):7 * codex mcp list --json → [{"name":"t1","enabled":true,"disabled_reason":null,8 * "transport":{"type":"stdio","command":"npx","args":["-y","x"],"env":{"K":"<VALUE>"},9 * "env_vars":[],"cwd":null},10 * "startup_timeout_sec":null,"tool_timeout_sec":null,"auth_status":"unsupported"},11 * {"name":"t2",…,"transport":{"type":"streamable_http","url":"https://…",12 * "bearer_token_env_var":"MY_TOK","http_headers":null,"env_http_headers":null,…},13 * "auth_status":"bearer_token"}] (`env` holds the VALUES in clear: never forwarded)14 * auth_status ∈ unsupported | not_logged_in | bearer_token | o_auth15 * codex mcp add <name> [--env K=V …] -- <command> [args…] → `Added global MCP server '<name>'.`16 * codex mcp add <name> --url <url> [--bearer-token-env-var VAR] (an existing name is REPLACED silently)17 * codex mcp remove <name> → `Removed global MCP server '<name>'.`, or exit 0 with18 * `No MCP server named '<name>' found.`19 * codex mcp get <name> [--json] → exit 1 `Error: No MCP server named '<name>' found.`20 * There is one scope (global, `$CODEX_HOME/config.toml` → reported as `user`), no SSE transport,21 * no custom headers on `add`, and `list` does not health-check the servers.22 */23import type { McpServerInfo } from '@cr/shared';24import { MASK, maskArgs, sanitizeUrl, type AddSpec } from './fsx-mcp-config.js';2526type Unknown = Record<string, unknown>;27const isObj = (v: unknown): v is Unknown => typeof v === 'object' && v !== null && !Array.isArray(v);28const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []);29const quoteForDisplay = (arg: string): string => (/[\s"']/.test(arg) ? JSON.stringify(arg) : arg);30const NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,39}$/;3132/** One entry of `codex mcp list --json` → display info (no secret values). */33export function describeCodexServer(raw: unknown, checkedAt: string | null): McpServerInfo | null {34 if (!isObj(raw) || typeof raw.name !== 'string' || raw.name === '' || !isObj(raw.transport)) return null;35 const t = raw.transport;36 const type = typeof t.type === 'string' ? t.type.toLowerCase() : '';37 let transport: McpServerInfo['transport'];38 let target: string;39 const envKeys = new Set<string>();40 if (typeof t.url === 'string' && type !== 'stdio') {41 transport = type === 'sse' ? 'sse' : 'http';42 target = sanitizeUrl(t.url);43 if (typeof t.bearer_token_env_var === 'string' && t.bearer_token_env_var !== '') envKeys.add(t.bearer_token_env_var);44 // env_http_headers maps a header name to the NAME of an environment variable.45 if (isObj(t.env_http_headers)) for (const v of Object.values(t.env_http_headers)) if (typeof v === 'string') envKeys.add(v);46 } else if (typeof t.command === 'string') {47 transport = 'stdio';48 target = [t.command, ...maskArgs(strings(t.args))].map(quoteForDisplay).join(' ').slice(0, 1000);49 if (isObj(t.env)) for (const k of Object.keys(t.env)) envKeys.add(k); // names only50 for (const k of strings(t.env_vars)) envKeys.add(k);51 } else {52 return null;53 }54 // No live health check exists in this CLI: only what the configuration says.55 let status: McpServerInfo['status'] = 'unknown';56 let lastError: string | null = null;57 if (raw.enabled === false) lastError = 'Disabled';58 else if (raw.auth_status === 'not_logged_in') status = 'needs_auth';59 return { name: raw.name, scope: 'user', transport, target, envKeys: [...envKeys].sort(), status, lastError, checkedAt };60}6162/** Tolerant parser for `codex mcp list --json`; returns null when the output is not the expected array. */63export function parseCodexMcpList(stdout: string, checkedAt: string | null = null): McpServerInfo[] | null {64 const start = stdout.indexOf('[');65 const end = stdout.lastIndexOf(']');66 if (start < 0 || end < start) return null;67 let doc: unknown;68 try {69 doc = JSON.parse(stdout.slice(start, end + 1));70 } catch {71 return null;72 }73 if (!Array.isArray(doc)) return null;74 const out: McpServerInfo[] = [];75 for (const raw of doc) {76 const info = describeCodexServer(raw, checkedAt);77 if (info) out.push(info);78 }79 return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));80}8182/** What `codex mcp add` cannot express. Returns the reason, or null. (Run after `validateAdd`.) */83export function validateCodexAdd(spec: AddSpec): string | null {84 if (!NAME_RE.test(spec.name)) return 'invalid server name';85 if (spec.scope !== 'user') return 'Codex has a single (user) MCP scope';86 if (spec.transport === 'sse') return 'Codex does not support SSE MCP servers (use a streamable HTTP URL)';87 if (Object.keys(spec.headers).length > 0) return 'Codex cannot store custom headers from the CLI';88 // `--env K=V`: the CLI splits on the first "=", so a key can never contain one (validateAdd).89 if (spec.transport === 'stdio' && spec.target.startsWith('-')) return 'invalid command';90 return null;91}9293/** argv for `codex mcp add` (after the binary). Contains secrets: never log it. */94export function buildCodexAddArgv(spec: AddSpec): string[] {95 if (spec.transport === 'stdio') {96 const env = Object.entries(spec.env).flatMap(([k, v]) => ['--env', `${k}=${v}`]);97 return ['mcp', 'add', spec.name, ...env, '--', spec.target, ...spec.args];98 }99 return ['mcp', 'add', spec.name, '--url', spec.target];100}101export function buildCodexRemoveArgv(name: string): string[] {102 return ['mcp', 'remove', name];103}104export function buildCodexGetArgv(name: string): string[] {105 return ['mcp', 'get', name, '--json'];106}107export function buildCodexListArgv(): string[] {108 return ['mcp', 'list', '--json'];109}110111/** `No MCP server named 'x' found.` — printed by `get` (exit 1) and by `remove` (exit 0). */112export function isCodexMcpNotFound(text: string): boolean {113 return /no mcp server named/i.test(text);114}115116export { MASK };117