@minhspark/codex-mcp-bridge 1.12.1 → 1.12.2

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 CHANGED
@@ -2,6 +2,8 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
+ import path from "node:path";
6
+ import { realpathSync } from "node:fs";
5
7
 
6
8
  import { CodexAppServerClient, writerLockWarning } from "./app-server-client.mjs";
7
9
  import {
@@ -21,7 +23,7 @@ import {
21
23
  import { runTurn } from "./turn.mjs";
22
24
  import { BridgeSecurityPolicy } from "./security-policy.mjs";
23
25
 
24
- const VERSION = "1.12.1";
26
+ const VERSION = "1.12.2";
25
27
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
26
28
 
27
29
  /**
@@ -38,7 +40,7 @@ const DEFAULT_RELEASE_AFTER_TURN = process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN
38
40
  ? process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN === "1"
39
41
  : IS_WINDOWS;
40
42
  const TERMINAL_TURN_STATUSES = new Set(["completed", "interrupted", "failed"]);
41
- const RELEASE_TURN_STATUSES = new Set([...TERMINAL_TURN_STATUSES, "disconnected"]);
43
+ const RELEASE_TURN_STATUSES = TERMINAL_TURN_STATUSES;
42
44
  const security = new BridgeSecurityPolicy();
43
45
 
44
46
  const client = new CodexAppServerClient({
@@ -115,6 +117,15 @@ async function createCodexThread({ cwd, model, name, prompt }) {
115
117
  });
116
118
  const thread = res?.thread ?? {};
117
119
  if (!thread.id) throw new Error("Codex app-server created no thread id");
120
+ try {
121
+ security.assertCwd(thread.cwd);
122
+ if (!path.isAbsolute(thread.cwd) || path.relative(realpathSync(workspace.path), realpathSync(thread.cwd))) {
123
+ throw new Error("Codex app-server created the thread in a different workspace than requested");
124
+ }
125
+ } catch (err) {
126
+ await client.releaseThread(thread.id).catch(() => {});
127
+ throw err;
128
+ }
118
129
  const threadName = name || prompt ? threadNameFor({ cwd: thread.cwd ?? workspace.path, prompt, name }) : null;
119
130
  if (threadName) {
120
131
  await client.call("thread/name/set", { threadId: thread.id, name: threadName });
@@ -138,26 +149,18 @@ async function finishDesktopHandoff({ threadId, result, openInApp, releaseAfterT
138
149
 
139
150
  if (releaseAfterTurn && releasable) {
140
151
  try {
141
- const released = await client.stopServer();
142
- if (released.stopped) {
143
- if (released.stillListening) {
144
- canOpenAfterRelease = false;
145
- notes.push(
146
- `stop requested for app-server${released.pids?.length ? ` (pid ${released.pids.join(", ")})` : ""}, but it is still listening`,
147
- );
148
- notes.push("WARNING: the app-server is still listening, so the desktop thread was not opened to avoid another lock");
149
- } else {
150
- notes.push(
151
- `released app-server${released.pids?.length ? ` (pid ${released.pids.join(", ")})` : ""}; Codex Desktop can write this thread`,
152
- );
153
- }
152
+ const released = await client.releaseThread(threadId);
153
+ if (released.released) {
154
+ notes.push(`released thread ${threadId}; other app-server threads remain active`);
154
155
  } else {
155
156
  canOpenAfterRelease = false;
156
- notes.push(`could not release app-server: ${released.reason}`);
157
+ notes.push(released.unsubscribed
158
+ ? `unsubscribed from thread ${threadId}; desktop opening is deferred until the server unloads it`
159
+ : `could not release thread: ${released.reason ?? released.status}`);
157
160
  }
158
161
  } catch (err) {
159
162
  canOpenAfterRelease = false;
160
- notes.push(`could not release app-server: ${err.message}`);
163
+ notes.push(`could not release thread: ${err.message}`);
161
164
  }
162
165
  }
163
166
 
@@ -267,7 +270,7 @@ server.registerTool(
267
270
  releaseAfterTurn: z
268
271
  .boolean()
269
272
  .optional()
270
- .describe("Stop the bridge app-server after a terminal turn so Codex Desktop owns the writer lock"),
273
+ .describe("Unsubscribe this thread after a terminal turn; open Desktop only after its unload is confirmed"),
271
274
  },
272
275
  annotations: {
273
276
  readOnlyHint: false,
@@ -358,7 +361,7 @@ server.registerTool(
358
361
  releaseAfterTurn: z
359
362
  .boolean()
360
363
  .optional()
361
- .describe("Stop the bridge app-server after a terminal turn so Codex Desktop owns the writer lock"),
364
+ .describe("Unsubscribe this thread after a terminal turn; open Desktop only after its unload is confirmed"),
362
365
  },
363
366
  annotations: {
364
367
  readOnlyHint: false,
@@ -368,64 +371,66 @@ server.registerTool(
368
371
  },
369
372
  },
370
373
  async ({ threadId, prompt, timeoutSec, cwd, model, effort, name, openInApp, releaseAfterTurn }) => {
371
- const notes = [];
372
- const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
373
- const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
374
- try {
375
- const authorizedThread = await assertThreadAccess(threadId);
376
- let resolvedCwd = null;
377
- if (cwd) {
378
- const workspace = resolveWorkspacePath(cwd);
379
- security.assertCwd(workspace.path);
380
- resolvedCwd = workspace.path;
381
- if (workspace.note) notes.push(workspace.note);
382
- } else if (authorizedThread?.cwd) {
383
- const workspace = resolveWorkspacePath(authorizedThread.cwd);
384
- resolvedCwd = workspace.path;
385
- if (workspace.note) notes.push(workspace.note);
386
- }
387
- const attached = await client.ensureThreadAttached(threadId, resolvedCwd ? { cwd: resolvedCwd } : {});
388
- const attachedThread = normalizeThreadCwd(attached.thread ?? authorizedThread, { strict: true });
389
- security.assertCwd(attachedThread?.cwd);
390
- if (name) {
391
- await client.call("thread/name/set", { threadId, name: name.trim().slice(0, 200) });
392
- notes.push(`session name: ${name.trim().slice(0, 200)}`);
393
- }
394
- /**
395
- * Opening the thread in the app comes after both gates. It ran first
396
- * once, which meant a thread this bridge was about to refuse still got
397
- * raised on screen - a refusal that leaked which threads exist.
398
- */
399
- if (shouldOpen && !shouldRelease) {
400
- try {
401
- notes.push(`opened in Codex app: ${await openThreadInCodexApp(threadId)}`);
402
- } catch (err) {
403
- notes.push(`could not open the thread in the Codex app: ${err.message}`);
374
+ return client.withThread(threadId, async () => {
375
+ const notes = [];
376
+ const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
377
+ const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
378
+ try {
379
+ const authorizedThread = await assertThreadAccess(threadId);
380
+ let resolvedCwd = null;
381
+ if (cwd) {
382
+ const workspace = resolveWorkspacePath(cwd);
383
+ security.assertCwd(workspace.path);
384
+ resolvedCwd = workspace.path;
385
+ if (workspace.note) notes.push(workspace.note);
386
+ } else if (authorizedThread?.cwd) {
387
+ const workspace = resolveWorkspacePath(authorizedThread.cwd);
388
+ resolvedCwd = workspace.path;
389
+ if (workspace.note) notes.push(workspace.note);
390
+ }
391
+ const attached = await client.ensureThreadAttached(threadId, resolvedCwd ? { cwd: resolvedCwd } : {});
392
+ const attachedThread = normalizeThreadCwd(attached.thread ?? authorizedThread, { strict: true });
393
+ security.assertCwd(attachedThread?.cwd);
394
+ if (name) {
395
+ await client.call("thread/name/set", { threadId, name: name.trim().slice(0, 200) });
396
+ notes.push(`session name: ${name.trim().slice(0, 200)}`);
404
397
  }
398
+ /**
399
+ * Opening the thread in the app comes after both gates. It ran first
400
+ * once, which meant a thread this bridge was about to refuse still got
401
+ * raised on screen - a refusal that leaked which threads exist.
402
+ */
403
+ if (shouldOpen && !shouldRelease) {
404
+ try {
405
+ notes.push(`opened in Codex app: ${await openThreadInCodexApp(threadId)}`);
406
+ } catch (err) {
407
+ notes.push(`could not open the thread in the Codex app: ${err.message}`);
408
+ }
409
+ }
410
+ const result = await runTurn(client, {
411
+ threadId,
412
+ input: [{ type: "text", text: prompt }],
413
+ timeoutMs: (timeoutSec ?? 240) * 1000,
414
+ turnOverrides: {
415
+ ...(resolvedCwd ? { cwd: resolvedCwd } : {}),
416
+ ...(model ?? DEFAULT_MODEL ? { model: model ?? DEFAULT_MODEL } : {}),
417
+ ...(effort ?? DEFAULT_EFFORT ? { effort: effort ?? DEFAULT_EFFORT } : {}),
418
+ },
419
+ });
420
+ const body = formatTurn(result);
421
+ const failed = result.status === "failed" || result.status === "disconnected";
422
+ notes.push(...(await finishDesktopHandoff({
423
+ threadId,
424
+ result,
425
+ openInApp: shouldOpen,
426
+ releaseAfterTurn: shouldRelease,
427
+ })));
428
+ const held = shouldOpen && !shouldRelease && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
429
+ return textResult(`${notes.length ? `${notes.join("\n")}\n` : ""}${body}${held}`, failed);
430
+ } catch (err) {
431
+ return failure(err);
405
432
  }
406
- const result = await runTurn(client, {
407
- threadId,
408
- input: [{ type: "text", text: prompt }],
409
- timeoutMs: (timeoutSec ?? 240) * 1000,
410
- turnOverrides: {
411
- ...(resolvedCwd ? { cwd: resolvedCwd } : {}),
412
- ...(model ?? DEFAULT_MODEL ? { model: model ?? DEFAULT_MODEL } : {}),
413
- ...(effort ?? DEFAULT_EFFORT ? { effort: effort ?? DEFAULT_EFFORT } : {}),
414
- },
415
- });
416
- const body = formatTurn(result);
417
- const failed = result.status === "failed" || result.status === "disconnected";
418
- notes.push(...(await finishDesktopHandoff({
419
- threadId,
420
- result,
421
- openInApp: shouldOpen,
422
- releaseAfterTurn: shouldRelease,
423
- })));
424
- const held = shouldOpen && !shouldRelease && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
425
- return textResult(`${notes.length ? `${notes.join("\n")}\n` : ""}${body}${held}`, failed);
426
- } catch (err) {
427
- return failure(err);
428
- }
433
+ });
429
434
  },
430
435
  );
431
436
 
@@ -459,16 +464,40 @@ server.registerTool(
459
464
  }
460
465
  if (searchTerm) params.searchTerm = searchTerm;
461
466
  const method = loadedOnly ? "thread/loaded/list" : "thread/list";
462
- const res = await client.call(method, loadedOnly ? { limit: limit ?? 15 } : params);
463
- const rows = security.filterThreads(
464
- (res?.data ?? res?.threads ?? []).flatMap((thread) => {
467
+ const rows = [];
468
+ const seenIds = new Set();
469
+ const seenCursors = new Set();
470
+ let cursor;
471
+ do {
472
+ const res = await client.call(method, loadedOnly ? { limit: params.limit, ...(cursor ? { cursor } : {}) } : params);
473
+ let threads = res?.data ?? res?.threads ?? [];
474
+ if (loadedOnly) {
475
+ threads = await Promise.all(threads.map(async (threadId) => {
476
+ if (seenIds.has(threadId)) return null;
477
+ seenIds.add(threadId);
478
+ try {
479
+ const read = await client.call("thread/read", { threadId });
480
+ return read?.thread ?? null;
481
+ } catch {
482
+ return null;
483
+ }
484
+ }));
485
+ }
486
+ rows.push(...security.filterThreads(threads.flatMap((thread) => {
465
487
  try {
466
- return [normalizeThreadCwd(thread, { strict: true })];
488
+ const normalized = normalizeThreadCwd(thread, { strict: true });
489
+ if (loadedOnly && params.cwd && (!normalized?.cwd || path.relative(params.cwd.paths[0], normalized.cwd))) return [];
490
+ if (loadedOnly && searchTerm && !String(normalized?.name ?? normalized?.preview ?? "").toLowerCase().includes(searchTerm.toLowerCase())) return [];
491
+ return [normalized];
467
492
  } catch {
468
493
  return [];
469
494
  }
470
- }),
471
- );
495
+ })));
496
+ cursor = res?.nextCursor;
497
+ if (!loadedOnly || !cursor || seenCursors.has(cursor)) break;
498
+ seenCursors.add(cursor);
499
+ } while (rows.length < params.limit);
500
+ rows.splice(params.limit);
472
501
  if (!rows.length) {
473
502
  return textResult(
474
503
  security.summary().allowedRoots.length
@@ -648,6 +677,9 @@ server.registerTool(
648
677
  async () => {
649
678
  try {
650
679
  const result = await client.stopServer();
680
+ if (result.stillListening) {
681
+ return textResult("The app-server is still listening after the stop request; its thread writer locks are not confirmed released.", true);
682
+ }
651
683
  return textResult(
652
684
  result.stopped
653
685
  ? `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.`
@@ -6,32 +6,18 @@ import { fileURLToPath } from "node:url";
6
6
 
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
- import { z } from "zod";
10
9
 
11
10
  import {
12
11
  MAX_FRAME_BYTES,
13
12
  NATIVE_DISPATCH_METHOD,
13
+ NativeToolsClient,
14
14
  RELAY_PROTOCOL_VERSION,
15
- nativeDispatchParams,
16
15
  relaySocketPath,
17
16
  resolveRelayThreadId,
18
17
  } from "./native-relay.mjs";
19
18
  import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
20
19
 
21
- /**
22
- * The companion half of the Codex Desktop native relay.
23
- *
24
- * Codex Desktop launches this as one of its own MCP servers, so the connection
25
- * it answers on belongs to the app's real app-server - the one already holding
26
- * the writer lock of every thread the human has open. Asking that app-server to
27
- * deliver a message is therefore not a second writer, and the thread stays open
28
- * and owned by Codex Desktop throughout.
29
- *
30
- * Everything else is deliberately small: a private socket, one accepted shape
31
- * (`{ targetThreadId, message }`), one dispatch, one acknowledgement.
32
- */
33
-
34
- const VERSION = "1.12.1";
20
+ const VERSION = "1.12.2";
35
21
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
36
22
 
37
23
  function errorResponse(code, message) {
@@ -56,6 +42,11 @@ export async function handleRelayRequest(
56
42
  payload,
57
43
  { dispatch, resolveExecutor = resolveRelayThreadId, env = process.env } = {},
58
44
  ) {
45
+ if (!payload || typeof payload !== "object" || Array.isArray(payload) ||
46
+ Object.keys(payload).some((key) => !["v", "targetThreadId", "message"].includes(key)) ||
47
+ (payload.v !== undefined && payload.v !== RELAY_PROTOCOL_VERSION)) {
48
+ return errorResponse("RELAY_BAD_REQUEST", "expected a relay request with targetThreadId and message");
49
+ }
59
50
  const targetThreadId = typeof payload?.targetThreadId === "string" ? payload.targetThreadId.trim() : "";
60
51
  const message = typeof payload?.message === "string" ? payload.message : "";
61
52
 
@@ -84,6 +75,10 @@ export async function handleRelayRequest(
84
75
 
85
76
  try {
86
77
  const result = await dispatch({ executorThreadId, targetThreadId, message });
78
+ if (result?.success !== true || result?.isError === true) {
79
+ const detail = typeof result?.error === "string" ? result.error : result?.error?.message;
80
+ return errorResponse("NATIVE_DISPATCH_FAILED", detail ?? "Codex Desktop did not confirm successful native dispatch");
81
+ }
87
82
  return { ok: true, v: RELAY_PROTOCOL_VERSION, targetThreadId, executorThreadId, result: result ?? null };
88
83
  } catch (err) {
89
84
  return errorResponse(errorCode(err), err?.message ?? String(err));
@@ -113,6 +108,8 @@ export class RelaySocketServer {
113
108
  this.log = logFn;
114
109
  this.server = null;
115
110
  this.started = false;
111
+ this.connections = new Set();
112
+ this.processHandlers = new Map();
116
113
  }
117
114
 
118
115
  async start() {
@@ -139,12 +136,16 @@ export class RelaySocketServer {
139
136
  this.started = true;
140
137
 
141
138
  for (const signal of ["SIGINT", "SIGTERM"]) {
142
- process.on(signal, () => {
139
+ const handler = () => {
143
140
  this.stop();
144
141
  process.exit(0);
145
- });
142
+ };
143
+ this.processHandlers.set(signal, handler);
144
+ process.on(signal, handler);
146
145
  }
147
- process.on("exit", () => this.stop());
146
+ const onExit = () => this.stop();
147
+ this.processHandlers.set("exit", onExit);
148
+ process.on("exit", onExit);
148
149
 
149
150
  this.log(`relay socket listening on ${this.socketPath}`);
150
151
  return this.socketPath;
@@ -201,22 +202,25 @@ export class RelaySocketServer {
201
202
  }
202
203
 
203
204
  #handleConnection(socket) {
204
- let buffer = "";
205
+ let buffer = Buffer.alloc(0);
206
+ let handled = false;
207
+ this.connections.add(socket);
208
+ socket.on("close", () => this.connections.delete(socket));
209
+ socket.setTimeout(30000, () => socket.destroy());
205
210
  socket.on("error", (err) => this.log(`relay socket error: ${err.message}`));
206
211
  socket.on("data", (chunk) => {
207
- buffer += chunk.toString("utf8");
208
- if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
212
+ if (handled) return;
213
+ buffer = Buffer.concat([buffer, chunk]);
214
+ if (buffer.length > MAX_FRAME_BYTES) {
215
+ handled = true;
209
216
  this.#reply(socket, errorResponse("RELAY_MESSAGE_TOO_LARGE", `a relay frame may not exceed ${MAX_FRAME_BYTES} bytes`));
210
- socket.destroy();
211
217
  return;
212
218
  }
213
- let index;
214
- while ((index = buffer.indexOf("\n")) >= 0) {
215
- const line = buffer.slice(0, index).trim();
216
- buffer = buffer.slice(index + 1);
217
- if (!line) continue;
218
- void this.#handleLine(socket, line);
219
- }
219
+ const index = buffer.indexOf(10);
220
+ if (index < 0) return;
221
+ handled = true;
222
+ void this.#handleLine(socket, buffer.subarray(0, index).toString("utf8"));
223
+ buffer = Buffer.alloc(0);
220
224
  });
221
225
  }
222
226
 
@@ -239,10 +243,14 @@ export class RelaySocketServer {
239
243
 
240
244
  #reply(socket, response) {
241
245
  if (socket.destroyed) return;
242
- socket.write(`${JSON.stringify(response)}\n`);
246
+ socket.end(`${JSON.stringify(response)}\n`);
243
247
  }
244
248
 
245
249
  stop() {
250
+ for (const [event, handler] of this.processHandlers) process.off(event, handler);
251
+ this.processHandlers.clear();
252
+ for (const socket of this.connections) socket.destroy();
253
+ this.connections.clear();
246
254
  try {
247
255
  this.server?.close();
248
256
  } catch {}
@@ -253,6 +261,58 @@ export class RelaySocketServer {
253
261
  }
254
262
  }
255
263
 
264
+ export function startRelayWhenAvailable({ nativeTools, relay, log: logFn = () => {}, retryDelayMs = 250, maxRetryDelayMs = 30000 }) {
265
+ let stopped = false;
266
+ let timer = null;
267
+ let delayMs = retryDelayMs;
268
+ let resolveReady;
269
+ const ready = new Promise((resolve) => { resolveReady = resolve; });
270
+ const attempt = async () => {
271
+ if (stopped) return;
272
+ try {
273
+ await nativeTools.connect();
274
+ if (stopped) {
275
+ nativeTools.close();
276
+ return;
277
+ }
278
+ await relay.start();
279
+ if (stopped) {
280
+ relay.stop();
281
+ nativeTools.close();
282
+ return;
283
+ }
284
+ resolveReady(true);
285
+ } catch (err) {
286
+ if (stopped) return;
287
+ nativeTools.close();
288
+ if (!nativeTools.socketPath) {
289
+ logFn(`native relay unavailable (${err.message})`);
290
+ resolveReady(false);
291
+ return;
292
+ }
293
+ logFn(`native relay unavailable (${err.message}); retrying in ${delayMs}ms`);
294
+ timer = globalThis.setTimeout(() => {
295
+ timer = null;
296
+ void attempt();
297
+ }, delayMs);
298
+ timer.unref();
299
+ delayMs = Math.min(delayMs * 2, maxRetryDelayMs);
300
+ }
301
+ };
302
+ void attempt();
303
+ return {
304
+ ready,
305
+ stop() {
306
+ if (stopped) return;
307
+ stopped = true;
308
+ globalThis.clearTimeout(timer);
309
+ nativeTools.close();
310
+ relay.stop();
311
+ resolveReady(false);
312
+ },
313
+ };
314
+ }
315
+
256
316
  /**
257
317
  * `import.meta.main` is Node 24 and up, and this project supports Node 22, so
258
318
  * the entry point is detected by comparing the resolved argv path instead.
@@ -271,20 +331,8 @@ if (invokedDirectly) {
271
331
  },
272
332
  );
273
333
 
274
- /**
275
- * The dispatch goes back over the very connection Codex Desktop opened to
276
- * launch this process, which is what keeps the app the single writer. Sent as
277
- * a plain JSON-RPC request rather than through a typed helper because the
278
- * method is an internal of the app, not part of the MCP specification.
279
- */
280
- const dispatch = ({ executorThreadId, targetThreadId, message }) =>
281
- mcp.server.request(
282
- {
283
- method: process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD,
284
- params: nativeDispatchParams({ executorThreadId, targetThreadId, message }),
285
- },
286
- z.any(),
287
- );
334
+ const nativeTools = new NativeToolsClient();
335
+ const dispatch = (args) => nativeTools.dispatch(args);
288
336
 
289
337
  const relay = new RelaySocketServer({ socketPath: relaySocketPath(), dispatch, log });
290
338
 
@@ -319,6 +367,7 @@ if (invokedDirectly) {
319
367
  `relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (not listening)`}`,
320
368
  `executor: ${executor}`,
321
369
  `dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
370
+ `native pipe: ${nativeTools.socketPath ?? "unavailable (requires Codex Desktop)"}`,
322
371
  ].join("\n"),
323
372
  },
324
373
  ],
@@ -326,18 +375,8 @@ if (invokedDirectly) {
326
375
  },
327
376
  );
328
377
 
329
- /**
330
- * Never let the socket take the MCP server down. Codex Desktop waits on the
331
- * `initialize` handshake, so a process that dies before answering reads as a
332
- * hang rather than an error - the same failure mode `claude-bridge` already
333
- * guards its peer endpoint against.
334
- */
335
- try {
336
- await relay.start();
337
- } catch (err) {
338
- log(`relay socket unavailable (${err.message}) - claude-bridge will fall back to the app-server path`);
339
- }
340
-
378
+ const startup = startRelayWhenAvailable({ nativeTools, relay, log });
379
+ mcp.server.onclose = () => startup.stop();
341
380
  await mcp.connect(new StdioServerTransport());
342
381
  log(`ready on ${PLATFORM_LABEL} (${relay.started ? relay.socketPath : "socket down"})`);
343
382
  }