@minhspark/codex-mcp-bridge 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.mjs ADDED
@@ -0,0 +1,477 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+
6
+ import { CodexAppServerClient, writerLockWarning } from "./app-server-client.mjs";
7
+ import {
8
+ IS_MACOS,
9
+ PLATFORM_LABEL,
10
+ claudeDesktopConfigPath,
11
+ codexThreadUrl,
12
+ hasCodexDesktopApp,
13
+ isDesktopAppServerRunning,
14
+ isLaunchAgentInstalled,
15
+ launchAgentPath,
16
+ openThreadInCodexApp,
17
+ resolveWorkspacePath,
18
+ } from "./platform.mjs";
19
+ import { runTurn } from "./turn.mjs";
20
+ import { BridgeSecurityPolicy } from "./security-policy.mjs";
21
+
22
+ const VERSION = "1.10.0";
23
+ const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
24
+
25
+ /**
26
+ * The Codex desktop app ignores ~/.codex/config.toml and runs its own model and
27
+ * effort, so a thread opened through the bridge would otherwise be weaker than
28
+ * the same work done in the app. These defaults keep both paths equivalent.
29
+ */
30
+ const DEFAULT_MODEL = process.env.CODEX_BRIDGE_MODEL || null;
31
+ const DEFAULT_EFFORT = process.env.CODEX_BRIDGE_EFFORT || null;
32
+ const security = new BridgeSecurityPolicy();
33
+
34
+ const client = new CodexAppServerClient({
35
+ clientInfo: { name: "codex-mcp-bridge", title: "Codex MCP Bridge", version: VERSION },
36
+ log,
37
+ });
38
+
39
+ const textResult = (text, isError = false) => ({
40
+ content: [{ type: "text", text }],
41
+ ...(isError ? { isError: true } : {}),
42
+ });
43
+
44
+ const failure = (err) => textResult(`Codex bridge error: ${err?.message ?? String(err)}`, true);
45
+
46
+ function formatThreadRow(t) {
47
+ const title = t.name || (t.preview ?? "").replace(/\s+/g, " ").slice(0, 70) || "(no title)";
48
+ const updated = t.updatedAt ? new Date(t.updatedAt * 1000).toISOString().replace("T", " ").slice(0, 16) : "?";
49
+ const status = t.status?.type ?? "?";
50
+ const deepLink = IS_MACOS && hasCodexDesktopApp() ? `\n open: ${codexThreadUrl(t.id)}` : "";
51
+ const authorized = security.isThreadAuthorized(t.id)
52
+ ? ""
53
+ : "\n NOT AUTHORIZED: add this id to CODEX_BRIDGE_ALLOWED_THREADS to send into it";
54
+ return `- ${t.id}\n title: ${title}\n cwd: ${t.cwd ?? "?"}\n updated: ${updated} status: ${status} source: ${t.source ?? "?"}${deepLink}${authorized}`;
55
+ }
56
+
57
+ function formatTurn(result) {
58
+ const lines = [];
59
+ lines.push(`thread: ${result.threadId}`);
60
+ lines.push(`turn: ${result.turnId ?? "?"} status: ${result.status}`);
61
+ if (result.durationMs != null) lines.push(`took: ${Math.round(result.durationMs / 1000)}s`);
62
+ if (result.activity.length) {
63
+ const trail = result.activity.slice(-12).map((a) => {
64
+ if (a.kind === "command") {
65
+ const cmd = Array.isArray(a.command) ? a.command.join(" ") : a.command;
66
+ return ` * run: ${String(cmd ?? "?").slice(0, 160)}${a.exitCode != null ? ` (exit ${a.exitCode})` : ""}`;
67
+ }
68
+ if (a.kind === "fileChange") return ` * edit: ${a.files.join(", ").slice(0, 200)}`;
69
+ if (a.kind === "mcpToolCall") return ` * tool: ${a.server}/${a.tool}`;
70
+ if (a.kind === "webSearch") return ` * search: ${a.query}`;
71
+ return ` * ${a.kind}`;
72
+ });
73
+ lines.push(`activity (${result.activity.length} items, last ${trail.length}):`, ...trail);
74
+ }
75
+ if (result.errors.length) {
76
+ lines.push(`errors: ${result.errors.map((e) => e.message ?? JSON.stringify(e)).join(" | ")}`);
77
+ }
78
+ lines.push("", "--- Codex reply ---", result.text || "(no assistant text was produced)");
79
+ if (result.status === "timeout") {
80
+ lines.push(
81
+ "",
82
+ "NOTE: the bridge stopped waiting, but the turn is still running inside Codex.",
83
+ `Read it later with read_codex_thread, or stop it with interrupt_codex_turn (turnId ${result.turnId}).`,
84
+ );
85
+ }
86
+ if (result.status === "disconnected") {
87
+ lines.push(
88
+ "",
89
+ "NOTE: the app-server connection dropped mid-turn - typically the machine slept, rebooted, or the",
90
+ "Codex desktop app reclaimed the shared state. The turn may have kept running inside Codex.",
91
+ `Reconnect happens on the next call: check with read_codex_thread (threadId ${result.threadId}).`,
92
+ );
93
+ }
94
+ return lines.join("\n");
95
+ }
96
+
97
+ const server = new McpServer(
98
+ { name: "codex-bridge", version: VERSION },
99
+ {
100
+ instructions:
101
+ "Bridge into a live Codex session. Use list_codex_threads to find the right threadId, " +
102
+ "then send_to_codex_thread to push a prompt into that exact thread and read Codex's reply. " +
103
+ "On macOS, open_codex_thread (or openInApp) surfaces the thread in the Codex desktop app so a " +
104
+ "human can watch it run, and codex_bridge_status reports how the bridge is wired on this machine.",
105
+ },
106
+ );
107
+
108
+ server.registerTool(
109
+ "send_to_codex_thread",
110
+ {
111
+ title: "Send a prompt to a Codex thread",
112
+ description:
113
+ "Send a prompt as a new user turn inside an existing Codex thread and wait for Codex to answer. " +
114
+ "The thread keeps its full history, cwd and model. Use list_codex_threads first if you do not know the threadId.",
115
+ inputSchema: {
116
+ threadId: z.string().describe("Codex thread id (UUID) - get it from list_codex_threads"),
117
+ prompt: z.string().describe("The message to send to Codex, exactly as a user would type it"),
118
+ timeoutSec: z
119
+ .number()
120
+ .int()
121
+ .min(10)
122
+ .max(3600)
123
+ .optional()
124
+ .describe("How long to wait for the turn to finish (default 240s)"),
125
+ cwd: z.string().optional().describe("Override the working directory for this turn"),
126
+ model: z.string().optional().describe("Override the model for this turn"),
127
+ effort: z
128
+ .enum(["minimal", "low", "medium", "high", "xhigh", "ultra"])
129
+ .optional()
130
+ .describe(`Override reasoning effort (default ${DEFAULT_EFFORT ?? "whatever ~/.codex/config.toml says"})`),
131
+ openInApp: z
132
+ .boolean()
133
+ .optional()
134
+ .describe("macOS only: open the thread in the Codex desktop app before sending so a human can watch it live"),
135
+ },
136
+ annotations: {
137
+ readOnlyHint: false,
138
+ destructiveHint: true,
139
+ idempotentHint: false,
140
+ openWorldHint: true,
141
+ },
142
+ },
143
+ async ({ threadId, prompt, timeoutSec, cwd, model, effort, openInApp }) => {
144
+ let openNote = null;
145
+ try {
146
+ security.assertThread(threadId);
147
+ if (openInApp) {
148
+ try {
149
+ openNote = `opened in Codex app: ${await openThreadInCodexApp(threadId)}`;
150
+ } catch (err) {
151
+ openNote = `could not open the thread in the Codex app: ${err.message}`;
152
+ }
153
+ }
154
+ let resolvedCwd = null;
155
+ if (cwd) {
156
+ const workspace = resolveWorkspacePath(cwd);
157
+ security.assertCwd(workspace.path);
158
+ resolvedCwd = workspace.path;
159
+ if (workspace.note) openNote = openNote ? `${openNote}\n${workspace.note}` : workspace.note;
160
+ }
161
+ const attached = await client.ensureThreadAttached(threadId, resolvedCwd ? { cwd: resolvedCwd } : {});
162
+ security.assertCwd(attached.thread?.cwd);
163
+ const result = await runTurn(client, {
164
+ threadId,
165
+ input: [{ type: "text", text: prompt }],
166
+ timeoutMs: (timeoutSec ?? 240) * 1000,
167
+ turnOverrides: {
168
+ ...(resolvedCwd ? { cwd: resolvedCwd } : {}),
169
+ ...(model ?? DEFAULT_MODEL ? { model: model ?? DEFAULT_MODEL } : {}),
170
+ ...(effort ?? DEFAULT_EFFORT ? { effort: effort ?? DEFAULT_EFFORT } : {}),
171
+ },
172
+ });
173
+ const body = formatTurn(result);
174
+ const failed = result.status === "failed" || result.status === "disconnected";
175
+ const held = openInApp && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
176
+ return textResult(`${openNote ? `${openNote}\n${body}` : body}${held}`, failed);
177
+ } catch (err) {
178
+ return failure(err);
179
+ }
180
+ },
181
+ );
182
+
183
+ server.registerTool(
184
+ "list_codex_threads",
185
+ {
186
+ title: "List Codex threads",
187
+ description:
188
+ "List recent Codex threads (id, title, cwd, last update, status) so you can pick the exact threadId to talk to.",
189
+ inputSchema: {
190
+ limit: z.number().int().min(1).max(50).optional().describe("How many threads to return (default 15)"),
191
+ cwd: z.string().optional().describe("Only threads whose session cwd matches this path exactly"),
192
+ searchTerm: z.string().optional().describe("Substring filter on the thread title"),
193
+ loadedOnly: z
194
+ .boolean()
195
+ .optional()
196
+ .describe("Only threads currently loaded/live inside this app-server (default false)"),
197
+ },
198
+ annotations: {
199
+ readOnlyHint: true,
200
+ openWorldHint: true,
201
+ },
202
+ },
203
+ async ({ limit, cwd, searchTerm, loadedOnly }) => {
204
+ try {
205
+ const params = { limit: limit ?? 15 };
206
+ if (cwd) {
207
+ const workspace = resolveWorkspacePath(cwd);
208
+ security.assertCwd(workspace.path);
209
+ params.cwd = { paths: [workspace.path] };
210
+ }
211
+ if (searchTerm) params.searchTerm = searchTerm;
212
+ const method = loadedOnly ? "thread/loaded/list" : "thread/list";
213
+ const res = await client.call(method, loadedOnly ? { limit: limit ?? 15 } : params);
214
+ const rows = security.filterThreads(res?.data ?? res?.threads ?? []);
215
+ if (!rows.length) {
216
+ return textResult(
217
+ security.summary().allowedRoots.length
218
+ ? "No Codex threads matched inside the allowed workspace roots."
219
+ : "No workspace roots are configured, so no thread can be listed. Set CODEX_BRIDGE_ALLOWED_ROOTS to one or more project directories.",
220
+ !security.summary().allowedRoots.length,
221
+ );
222
+ }
223
+ return textResult(
224
+ `${rows.length} Codex thread(s) via ${client.url}:\n\n${rows.map(formatThreadRow).join("\n")}`,
225
+ );
226
+ } catch (err) {
227
+ return failure(err);
228
+ }
229
+ },
230
+ );
231
+
232
+ server.registerTool(
233
+ "start_codex_thread",
234
+ {
235
+ title: "Start a new Codex thread",
236
+ description: "Create a brand new Codex thread in the shared app-server and return its threadId.",
237
+ inputSchema: {
238
+ cwd: z.string().describe("Absolute working directory for the new Codex session"),
239
+ model: z.string().optional().describe("Model override, e.g. gpt-5.6-luna"),
240
+ },
241
+ annotations: {
242
+ readOnlyHint: false,
243
+ destructiveHint: false,
244
+ idempotentHint: false,
245
+ openWorldHint: true,
246
+ },
247
+ },
248
+ async ({ cwd, model }) => {
249
+ try {
250
+ const workspace = resolveWorkspacePath(cwd);
251
+ security.assertCwd(workspace.path);
252
+ const res = await client.call("thread/start", {
253
+ cwd: workspace.path,
254
+ ...(model ?? DEFAULT_MODEL ? { model: model ?? DEFAULT_MODEL } : {}),
255
+ approvalPolicy: security.approvalPolicy,
256
+ sandbox: security.sandbox,
257
+ });
258
+ const thread = res?.thread ?? {};
259
+ if (thread.id) {
260
+ client.markAttached(thread.id, thread);
261
+ security.registerThread(thread.id);
262
+ }
263
+ return textResult(
264
+ [
265
+ "Created Codex thread",
266
+ ` threadId: ${thread.id}`,
267
+ ` cwd: ${thread.cwd}`,
268
+ ` rollout: ${thread.path ?? "(not written yet)"}`,
269
+ ...(workspace.note ? [` note: ${workspace.note}`] : []),
270
+ ].join("\n"),
271
+ );
272
+ } catch (err) {
273
+ return failure(err);
274
+ }
275
+ },
276
+ );
277
+
278
+ server.registerTool(
279
+ "read_codex_thread",
280
+ {
281
+ title: "Read a Codex thread",
282
+ description: "Read the recent conversation of a Codex thread without sending anything.",
283
+ inputSchema: {
284
+ threadId: z.string().describe("Codex thread id"),
285
+ limit: z.number().int().min(1).max(50).optional().describe("How many recent messages to show (default 10)"),
286
+ },
287
+ annotations: {
288
+ readOnlyHint: true,
289
+ openWorldHint: true,
290
+ },
291
+ },
292
+ async ({ threadId, limit }) => {
293
+ try {
294
+ security.assertThread(threadId);
295
+ const res = await client.call("thread/read", { threadId, includeTurns: true });
296
+ const thread = res?.thread ?? res ?? {};
297
+ security.assertCwd(thread.cwd);
298
+ const items = (thread.turns ?? []).flatMap((t) => t.items ?? []);
299
+ const msgs = items
300
+ .filter((i) => i?.type === "agentMessage" || i?.type === "userMessage")
301
+ .slice(-(limit ?? 10))
302
+ .map((i) => {
303
+ const body =
304
+ i.type === "userMessage"
305
+ ? (i.content ?? [])
306
+ .map((c) => (c.type === "text" ? c.text : `<${c.type}>`))
307
+ .join(" ")
308
+ : (i.text ?? "");
309
+ return `[${i.type === "userMessage" ? "user" : "codex"}] ${body.trim()}`;
310
+ });
311
+ const header = `thread ${threadId}\n title: ${thread.name ?? "(unnamed)"}\n cwd: ${thread.cwd ?? "?"}\n status: ${thread.status?.type ?? "?"}`;
312
+ return textResult(msgs.length ? `${header}\n\n${msgs.join("\n\n")}` : `${header}\n\n(no messages found)`);
313
+ } catch (err) {
314
+ return failure(err);
315
+ }
316
+ },
317
+ );
318
+
319
+ server.registerTool(
320
+ "interrupt_codex_turn",
321
+ {
322
+ title: "Interrupt a Codex turn",
323
+ description: "Stop a turn that is still running in a Codex thread.",
324
+ inputSchema: {
325
+ threadId: z.string().describe("Codex thread id"),
326
+ turnId: z.string().describe("Turn id reported by send_to_codex_thread"),
327
+ },
328
+ annotations: {
329
+ readOnlyHint: false,
330
+ destructiveHint: true,
331
+ idempotentHint: true,
332
+ openWorldHint: true,
333
+ },
334
+ },
335
+ async ({ threadId, turnId }) => {
336
+ try {
337
+ security.assertThread(threadId);
338
+ const thread = await client.call("thread/read", { threadId });
339
+ security.assertCwd((thread?.thread ?? thread)?.cwd);
340
+ await client.call("turn/interrupt", { threadId, turnId });
341
+ return textResult(`Interrupted turn ${turnId} in thread ${threadId}.`);
342
+ } catch (err) {
343
+ return failure(err);
344
+ }
345
+ },
346
+ );
347
+
348
+ server.registerTool(
349
+ "open_codex_thread",
350
+ {
351
+ title: "Open a Codex thread in the desktop app",
352
+ description:
353
+ "macOS only: bring a Codex thread to the front in the Codex desktop app (codex://threads/<id>) " +
354
+ "so a human can watch the work live instead of reading the transcript afterwards.",
355
+ inputSchema: {
356
+ threadId: z.string().describe("Codex thread id"),
357
+ background: z
358
+ .boolean()
359
+ .optional()
360
+ .describe("Open without stealing focus from the current app (default false)"),
361
+ },
362
+ annotations: {
363
+ readOnlyHint: false,
364
+ destructiveHint: false,
365
+ idempotentHint: true,
366
+ openWorldHint: false,
367
+ },
368
+ },
369
+ async ({ threadId, background }) => {
370
+ try {
371
+ security.assertThread(threadId);
372
+ const thread = await client.call("thread/read", { threadId });
373
+ security.assertCwd((thread?.thread ?? thread)?.cwd);
374
+ const url = await openThreadInCodexApp(threadId, { activate: !background });
375
+ const held = client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
376
+ return textResult(`Opened ${url} in the Codex desktop app.${held}`);
377
+ } catch (err) {
378
+ return textResult(`${err.message}`, true);
379
+ }
380
+ },
381
+ );
382
+
383
+ server.registerTool(
384
+ "stop_codex_app_server",
385
+ {
386
+ title: "Stop the shared Codex app-server",
387
+ description:
388
+ "Stop the shared app-server this bridge talks to. Use it when work is handed off and the Codex desktop " +
389
+ "app is open: two app-servers on the same ~/.codex state make the app stutter. The bridge starts a new " +
390
+ "one automatically the next time it needs it.",
391
+ inputSchema: {},
392
+ annotations: {
393
+ readOnlyHint: false,
394
+ destructiveHint: true,
395
+ idempotentHint: true,
396
+ openWorldHint: false,
397
+ },
398
+ },
399
+ async () => {
400
+ try {
401
+ const result = await client.stopServer();
402
+ return textResult(
403
+ result.stopped
404
+ ? `Stopped the shared app-server (pid ${result.pids.join(", ")}). Its thread writer locks are released, so the Codex desktop app now owns ~/.codex and every thread it was holding.`
405
+ : `Nothing to stop: ${result.reason}.`,
406
+ );
407
+ } catch (err) {
408
+ return failure(err);
409
+ }
410
+ },
411
+ );
412
+
413
+ server.registerTool(
414
+ "codex_bridge_status",
415
+ {
416
+ title: "Check the Codex bridge environment",
417
+ description:
418
+ "Report how this bridge is wired on the current machine: platform, resolved codex binary, " +
419
+ "app-server endpoint and whether it is live, plus the macOS integrations (LaunchAgent, desktop app).",
420
+ inputSchema: {},
421
+ annotations: {
422
+ readOnlyHint: true,
423
+ openWorldHint: false,
424
+ },
425
+ },
426
+ async () => {
427
+ const up = await client.isServerUp();
428
+ let liveThreads = null;
429
+ if (up) {
430
+ try {
431
+ const res = await client.call("thread/loaded/list", { limit: 20 });
432
+ liveThreads = (res?.data ?? res?.threads ?? []).length;
433
+ } catch {
434
+ liveThreads = null;
435
+ }
436
+ }
437
+ const lines = [
438
+ `platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
439
+ `bridge version: ${VERSION}`,
440
+ `node: ${process.version} at ${process.execPath}`,
441
+ `codex binary: ${client.codexBin}`,
442
+ `defaults: model ${DEFAULT_MODEL ?? "(from ~/.codex/config.toml)"}, effort ${DEFAULT_EFFORT ?? "(from ~/.codex/config.toml)"}`,
443
+ `app-server: ${client.url} - ${up ? "live" : "not reachable"}`,
444
+ `autostart: ${client.autoStart ? "on" : "off"} approvals: ${client.approval}`,
445
+ `security: ${security.summary().authorizedThreads} authorized thread(s), ${security.summary().allowedRoots.length} allowed root(s), sandbox ${security.sandbox}, thread policy ${security.approvalPolicy}`,
446
+ `live threads: ${liveThreads ?? "(unknown)"}`,
447
+ `claude desktop config: ${claudeDesktopConfigPath()}`,
448
+ ];
449
+ if (IS_MACOS) {
450
+ const desktopServer = isDesktopAppServerRunning();
451
+ lines.push(
452
+ `codex desktop app: ${hasCodexDesktopApp() ? "installed (codex:// deep links available)" : "not installed"}`,
453
+ `desktop app-server: ${desktopServer ? "running (its own stdio server)" : "not running"}`,
454
+ `launchd agent: ${isLaunchAgentInstalled() ? `installed at ${launchAgentPath()}` : "not installed"}`,
455
+ );
456
+ if (desktopServer && isLaunchAgentInstalled()) {
457
+ lines.push(
458
+ "",
459
+ "WARNING: the desktop app-server and the launchd app-server both hold the sqlite state in ~/.codex.",
460
+ "That contention makes the Codex app stutter. Keep only one alive:",
461
+ " node scripts/install-launch-agent.mjs --uninstall # let the desktop app own it",
462
+ );
463
+ }
464
+ }
465
+ if (!up) {
466
+ lines.push(
467
+ "",
468
+ `Start one with: ${client.codexBin} app-server --listen ${client.url}`,
469
+ );
470
+ }
471
+ return textResult(lines.join("\n"));
472
+ },
473
+ );
474
+
475
+ const transport = new StdioServerTransport();
476
+ await server.connect(transport);
477
+ log(`ready on ${PLATFORM_LABEL} (app-server endpoint: ${client.url}, codex: ${client.codexBin})`);