@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/CHANGELOG.md +223 -0
- package/LICENSE +21 -0
- package/README.md +444 -0
- package/package.json +62 -0
- package/scripts/check-claude-bridge.mjs +37 -0
- package/scripts/check.mjs +20 -0
- package/scripts/install-claude-desktop.mjs +67 -0
- package/scripts/install-codex-mcp.mjs +44 -0
- package/scripts/install-launch-agent.mjs +93 -0
- package/scripts/smoke.mjs +50 -0
- package/scripts/sync-version.mjs +25 -0
- package/src/app-server-client.mjs +414 -0
- package/src/claude-bridge.mjs +322 -0
- package/src/index.mjs +477 -0
- package/src/peer-protocol.mjs +367 -0
- package/src/platform.mjs +320 -0
- package/src/security-policy.mjs +149 -0
- package/src/turn.mjs +140 -0
|
@@ -0,0 +1,322 @@
|
|
|
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 } from "./app-server-client.mjs";
|
|
7
|
+
import { PLATFORM_LABEL } from "./platform.mjs";
|
|
8
|
+
import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
|
|
9
|
+
import { runTurn } from "./turn.mjs";
|
|
10
|
+
|
|
11
|
+
const VERSION = "1.3.0";
|
|
12
|
+
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
13
|
+
const FORWARD_MAX_PER_SESSION = 50;
|
|
14
|
+
|
|
15
|
+
const log = (msg) => process.stderr.write(`[claude-bridge] ${msg}\n`);
|
|
16
|
+
|
|
17
|
+
const defaultPeerName = process.env.CLAUDE_BRIDGE_PEER_NAME ?? `codex-${process.pid}`;
|
|
18
|
+
|
|
19
|
+
const peer = new PeerEndpoint({
|
|
20
|
+
name: defaultPeerName,
|
|
21
|
+
cwd: process.env.CLAUDE_BRIDGE_CWD ?? process.cwd(),
|
|
22
|
+
log,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const codex = new CodexAppServerClient({
|
|
26
|
+
clientInfo: { name: "claude-bridge", title: "Claude Bridge", version: VERSION },
|
|
27
|
+
log,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const forwarding = {
|
|
31
|
+
threadId: process.env.CODEX_THREAD_ID ?? null,
|
|
32
|
+
lastAt: 0,
|
|
33
|
+
count: 0,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const textResult = (text, isError = false) => ({
|
|
37
|
+
content: [{ type: "text", text }],
|
|
38
|
+
...(isError ? { isError: true } : {}),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const failure = (err) => textResult(`Claude bridge error: ${err?.message ?? String(err)}`, true);
|
|
42
|
+
|
|
43
|
+
function formatSessionRow(s) {
|
|
44
|
+
const started = s.startedAt ? new Date(s.startedAt).toISOString().replace("T", " ").slice(0, 16) : "?";
|
|
45
|
+
return `- ${s.name ?? "(unnamed)"} [pid ${s.pid}]\n session: ${s.sessionId ?? "?"}\n cwd: ${s.cwd ?? "?"}\n started: ${started} kind: ${s.kind ?? "?"} via: ${s.entrypoint ?? "?"}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A message Claude sends back only reaches the human if it lands in a Codex
|
|
50
|
+
* thread, so relay it into the bound thread instead of leaving it in a buffer
|
|
51
|
+
* nobody reads. Rate limited so two agents cannot ping-pong unattended.
|
|
52
|
+
*/
|
|
53
|
+
async function forwardToCodexThread(record) {
|
|
54
|
+
if (!forwarding.threadId) return;
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
if (now - forwarding.lastAt < FORWARD_MIN_INTERVAL_MS) {
|
|
57
|
+
log(`forward skipped (rate limit): ${record.text.slice(0, 60)}`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (forwarding.count >= FORWARD_MAX_PER_SESSION) {
|
|
61
|
+
log("forward skipped (per-session cap reached)");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
forwarding.lastAt = now;
|
|
65
|
+
forwarding.count += 1;
|
|
66
|
+
try {
|
|
67
|
+
await codex.ensureThreadAttached(forwarding.threadId);
|
|
68
|
+
await runTurn(codex, {
|
|
69
|
+
threadId: forwarding.threadId,
|
|
70
|
+
input: [
|
|
71
|
+
{
|
|
72
|
+
type: "text",
|
|
73
|
+
text: `[message from Claude session ${record.fromSocket ?? "?"}]\n\n${record.text}`,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
timeoutMs: 240000,
|
|
77
|
+
});
|
|
78
|
+
log(`forwarded a Claude message into thread ${forwarding.threadId}`);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
log(`forward failed: ${err.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
peer.onMessage((record) => {
|
|
85
|
+
void forwardToCodexThread(record);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const server = new McpServer(
|
|
89
|
+
{ name: "claude-bridge", version: VERSION },
|
|
90
|
+
{
|
|
91
|
+
instructions:
|
|
92
|
+
"Talk to a live Claude Code session from Codex. list_claude_sessions finds the session, " +
|
|
93
|
+
"send_to_claude_session delivers a message into its chat and waits for the answer. " +
|
|
94
|
+
"This bridge registers itself as a peer, so Claude sees it in its own agent list and can " +
|
|
95
|
+
"message back; bind_codex_thread relays those messages into a Codex thread.",
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
server.registerTool(
|
|
100
|
+
"list_claude_sessions",
|
|
101
|
+
{
|
|
102
|
+
title: "List live Claude Code sessions",
|
|
103
|
+
description:
|
|
104
|
+
"List Claude Code sessions running on this machine (name, pid, sessionId, cwd, how it was started). " +
|
|
105
|
+
"Use it to pick the session to talk to.",
|
|
106
|
+
inputSchema: {
|
|
107
|
+
includeDead: z.boolean().optional().describe("Also list sessions whose process is gone (default false)"),
|
|
108
|
+
},
|
|
109
|
+
annotations: {
|
|
110
|
+
readOnlyHint: true,
|
|
111
|
+
openWorldHint: true,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
async ({ includeDead }) => {
|
|
115
|
+
try {
|
|
116
|
+
const sessions = listClaudeSessions({ includeDead: includeDead ?? false }).filter(
|
|
117
|
+
(s) => s.pid !== process.pid,
|
|
118
|
+
);
|
|
119
|
+
if (!sessions.length) return textResult("No live Claude Code session found.");
|
|
120
|
+
return textResult(`${sessions.length} Claude session(s):\n\n${sessions.map(formatSessionRow).join("\n")}`);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return failure(err);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
server.registerTool(
|
|
128
|
+
"send_to_claude_session",
|
|
129
|
+
{
|
|
130
|
+
title: "Send a message to a Claude session",
|
|
131
|
+
description:
|
|
132
|
+
"Deliver a message into a running Claude Code session. It appears in that session's chat exactly like " +
|
|
133
|
+
"a message from a teammate, and Claude can reply. Set waitSec to 0 to fire and forget.",
|
|
134
|
+
inputSchema: {
|
|
135
|
+
target: z.string().describe("Session name, pid or sessionId from list_claude_sessions"),
|
|
136
|
+
message: z.string().describe("The message text to deliver"),
|
|
137
|
+
waitSec: z
|
|
138
|
+
.number()
|
|
139
|
+
.int()
|
|
140
|
+
.min(0)
|
|
141
|
+
.max(1800)
|
|
142
|
+
.optional()
|
|
143
|
+
.describe("How long to wait for Claude's reply (default 180, 0 = do not wait)"),
|
|
144
|
+
},
|
|
145
|
+
annotations: {
|
|
146
|
+
readOnlyHint: false,
|
|
147
|
+
destructiveHint: true,
|
|
148
|
+
idempotentHint: false,
|
|
149
|
+
openWorldHint: true,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
async ({ target, message, waitSec }) => {
|
|
153
|
+
try {
|
|
154
|
+
await peer.start();
|
|
155
|
+
const session = findClaudeSession(target);
|
|
156
|
+
if (!session) return textResult(`No live Claude session matches "${target}".`, true);
|
|
157
|
+
|
|
158
|
+
const since = Date.now();
|
|
159
|
+
await peer.send(session.socket, message);
|
|
160
|
+
const header = `delivered to ${session.name ?? session.pid} (pid ${session.pid}, session ${session.sessionId ?? "?"})`;
|
|
161
|
+
|
|
162
|
+
const wait = waitSec ?? 180;
|
|
163
|
+
if (wait === 0) return textResult(`${header}\nnot waiting for a reply.`);
|
|
164
|
+
|
|
165
|
+
const reply = await peer.waitForReply(session.socket, { timeoutMs: wait * 1000, since });
|
|
166
|
+
if (!reply) {
|
|
167
|
+
return textResult(
|
|
168
|
+
`${header}\n\nNo reply within ${wait}s. Claude may still be working - check again with read_claude_inbox.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return textResult(`${header}\n\n--- Claude reply ---\n${reply.text}`);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
return failure(err);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
server.registerTool(
|
|
179
|
+
"read_claude_inbox",
|
|
180
|
+
{
|
|
181
|
+
title: "Read messages Claude sent to this bridge",
|
|
182
|
+
description:
|
|
183
|
+
"Read and clear messages Claude sessions pushed to this bridge on their own (replies that arrived late, " +
|
|
184
|
+
"or messages Claude started).",
|
|
185
|
+
inputSchema: {
|
|
186
|
+
limit: z.number().int().min(1).max(100).optional().describe("How many messages to return (default 20)"),
|
|
187
|
+
},
|
|
188
|
+
annotations: {
|
|
189
|
+
readOnlyHint: false,
|
|
190
|
+
destructiveHint: true,
|
|
191
|
+
idempotentHint: false,
|
|
192
|
+
openWorldHint: true,
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
async ({ limit }) => {
|
|
196
|
+
try {
|
|
197
|
+
await peer.start();
|
|
198
|
+
const messages = peer.drainInbox(limit ?? 20);
|
|
199
|
+
if (!messages.length) return textResult("Inbox is empty.");
|
|
200
|
+
return textResult(
|
|
201
|
+
messages
|
|
202
|
+
.map((m) => {
|
|
203
|
+
const at = new Date(m.receivedAt).toISOString().replace("T", " ").slice(0, 19);
|
|
204
|
+
return `[${at}] from ${m.fromSocket ?? "?"}\n${m.text}`;
|
|
205
|
+
})
|
|
206
|
+
.join("\n\n"),
|
|
207
|
+
);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
return failure(err);
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
server.registerTool(
|
|
215
|
+
"read_claude_transcript",
|
|
216
|
+
{
|
|
217
|
+
title: "Read a Claude session transcript",
|
|
218
|
+
description: "Read the recent conversation of a Claude Code session without sending anything into it.",
|
|
219
|
+
inputSchema: {
|
|
220
|
+
target: z.string().describe("Session name, pid or sessionId from list_claude_sessions"),
|
|
221
|
+
limit: z.number().int().min(1).max(100).optional().describe("How many recent messages (default 10)"),
|
|
222
|
+
},
|
|
223
|
+
annotations: {
|
|
224
|
+
readOnlyHint: true,
|
|
225
|
+
openWorldHint: true,
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
async ({ target, limit }) => {
|
|
229
|
+
try {
|
|
230
|
+
const session = findClaudeSession(target);
|
|
231
|
+
if (!session) return textResult(`No live Claude session matches "${target}".`, true);
|
|
232
|
+
const { file, messages } = readTranscript(session.sessionId, session.cwd, limit ?? 10);
|
|
233
|
+
if (!messages.length) return textResult(`No transcript entries found (looked at ${file}).`);
|
|
234
|
+
const body = messages.map((m) => `[${m.role}] ${m.text}`).join("\n\n");
|
|
235
|
+
return textResult(`${session.name ?? session.pid} (${session.cwd})\n\n${body}`);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return failure(err);
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
server.registerTool(
|
|
243
|
+
"bind_codex_thread",
|
|
244
|
+
{
|
|
245
|
+
title: "Relay Claude messages into a Codex thread",
|
|
246
|
+
description:
|
|
247
|
+
"Bind a Codex thread so every message Claude pushes to this bridge is relayed into that thread, where it " +
|
|
248
|
+
"shows up in the Codex desktop app. Pass an empty threadId to stop relaying.",
|
|
249
|
+
inputSchema: {
|
|
250
|
+
threadId: z.string().describe("Codex thread id, or an empty string to unbind"),
|
|
251
|
+
},
|
|
252
|
+
annotations: {
|
|
253
|
+
readOnlyHint: false,
|
|
254
|
+
destructiveHint: false,
|
|
255
|
+
idempotentHint: true,
|
|
256
|
+
openWorldHint: false,
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
async ({ threadId }) => {
|
|
260
|
+
const trimmed = threadId.trim();
|
|
261
|
+
forwarding.threadId = trimmed || null;
|
|
262
|
+
forwarding.count = 0;
|
|
263
|
+
const name = trimmed ? `codex-${trimmed.slice(0, 8)}` : defaultPeerName;
|
|
264
|
+
peer.rename(name);
|
|
265
|
+
return textResult(
|
|
266
|
+
trimmed
|
|
267
|
+
? `Relaying Claude messages into Codex thread ${trimmed} (max ${FORWARD_MAX_PER_SESSION} per bridge run, at most one every ${FORWARD_MIN_INTERVAL_MS / 1000}s).\nClaude now sees this bridge as "${name}".`
|
|
268
|
+
: `Relay disabled. Messages stay in the inbox. Claude sees this bridge as "${name}".`,
|
|
269
|
+
);
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
server.registerTool(
|
|
274
|
+
"claude_bridge_status",
|
|
275
|
+
{
|
|
276
|
+
title: "Check the Claude bridge",
|
|
277
|
+
description:
|
|
278
|
+
"Report the peer endpoint this bridge exposes, how many Claude sessions are live, and whether messages " +
|
|
279
|
+
"are being relayed into a Codex thread.",
|
|
280
|
+
inputSchema: {},
|
|
281
|
+
annotations: {
|
|
282
|
+
readOnlyHint: false,
|
|
283
|
+
destructiveHint: false,
|
|
284
|
+
idempotentHint: true,
|
|
285
|
+
openWorldHint: false,
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
async () => {
|
|
289
|
+
try {
|
|
290
|
+
await peer.start();
|
|
291
|
+
const sessions = listClaudeSessions().filter((s) => s.pid !== process.pid);
|
|
292
|
+
const lines = [
|
|
293
|
+
`platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
|
|
294
|
+
`bridge: claude-bridge ${VERSION}`,
|
|
295
|
+
`peer name: ${peer.name} (Claude sees this in its agent list)`,
|
|
296
|
+
`peer socket: ${peer.socketPath}`,
|
|
297
|
+
`live sessions: ${sessions.length}`,
|
|
298
|
+
`relay thread: ${forwarding.threadId ?? "(none - use bind_codex_thread)"}`,
|
|
299
|
+
`inbox: ${peer.inbox.length} pending message(s)`,
|
|
300
|
+
];
|
|
301
|
+
return textResult(lines.join("\n"));
|
|
302
|
+
} catch (err) {
|
|
303
|
+
return failure(err);
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Never let peer registration take the MCP server down: a client that spawns
|
|
310
|
+
* this server waits on the initialize handshake, and a crash here shows up as
|
|
311
|
+
* a hung session rather than an error. Without the peer endpoint the bridge
|
|
312
|
+
* still lists sessions, reads transcripts and sends one-way messages.
|
|
313
|
+
*/
|
|
314
|
+
try {
|
|
315
|
+
await peer.start();
|
|
316
|
+
} catch (err) {
|
|
317
|
+
log(`peer endpoint unavailable (${err.message}) - replies from Claude cannot be received`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const transport = new StdioServerTransport();
|
|
321
|
+
await server.connect(transport);
|
|
322
|
+
log(`ready on ${PLATFORM_LABEL} as peer "${peer.name}" (${peer.started ? peer.socketPath : "peer endpoint down"})`);
|