SPB Git

Repository Public

codex-mcp.ts 6.1 KB 117 lines typescript

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
Files main
Menu

Theme