mcp-agents 0.5.8 → 0.6.5

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.
Files changed (3) hide show
  1. package/README.md +29 -5
  2. package/package.json +1 -1
  3. package/server.js +231 -20
package/README.md CHANGED
@@ -78,14 +78,29 @@ Any additional `tools/call` arguments are ignored (for example `model` or `model
78
78
  ### `codex` (pass-through)
79
79
 
80
80
  The codex provider passes through to Codex's native MCP server (`codex mcp-server`)
81
- using `-c key=value` config overrides:
81
+ inside an isolated `CODEX_HOME`. The bridge copies `auth.json` into a temporary Codex
82
+ home, writes a minimal `config.toml`, and does not inherit your normal external MCP
83
+ server list. That keeps Codex from recursively starting other agent tools like Claude
84
+ or Gemini during bridge calls.
82
85
 
83
86
  | CLI Flag | Default | Codex config key |
84
87
  |----------|---------|-----------------|
85
88
  | `--model` | `gpt-5.4` | `model` |
86
- | `--model_reasoning_effort` | `high` | `model_reasoning_effort` |
89
+ | `--model_reasoning_effort` | `xhigh` | `model_reasoning_effort` |
87
90
 
88
- Hardcoded defaults: `sandbox_mode=read-only`, `approval_policy=never` (safe for MCP server mode).
91
+ Hardcoded defaults: `sandbox_mode=read-only`, `approval_policy=never`,
92
+ `features.multi_agent=false`.
93
+
94
+ Startup flags set server-wide defaults for the native Codex MCP server. Per-call overrides still work through the native `codex` tool schema, for example:
95
+
96
+ ```json
97
+ {
98
+ "prompt": "Review this diff",
99
+ "config": {
100
+ "model_reasoning_effort": "medium"
101
+ }
102
+ }
103
+ ```
89
104
 
90
105
  ## Integration with Claude Code
91
106
 
@@ -119,7 +134,7 @@ Optional Gemini sandbox mode in `.mcp.json`:
119
134
  }
120
135
  ```
121
136
 
122
- Override codex defaults at server startup (not via `tools/call` arguments):
137
+ Override codex defaults at server startup:
123
138
 
124
139
  ```json
125
140
  {
@@ -132,6 +147,11 @@ Override codex defaults at server startup (not via `tools/call` arguments):
132
147
  }
133
148
  ```
134
149
 
150
+ The startup default can still be overridden for a single Codex tool call by passing `config.model_reasoning_effort` to the native `codex` tool.
151
+
152
+ Because the bridge runs in an isolated Codex home, inherited MCP servers from your normal
153
+ `~/.codex/config.toml` are intentionally unavailable inside bridged Codex sessions.
154
+
135
155
  <details>
136
156
  <summary>Alternative: using npx (slower, not recommended)</summary>
137
157
 
@@ -196,7 +216,11 @@ After `npm link`, any edits to `server.js` take effect immediately — no reinst
196
216
  4. Client calls `tools/call` with the tool name and a `prompt`
197
217
  5. The server runs the CLI as a child process and returns tool text (Claude JSON `result`, or stdout/stderr for other providers)
198
218
 
199
- The server includes a keepalive timer to prevent Node.js from exiting prematurely when stdin reaches EOF before the async subprocess registers an active handle.
219
+ The server keeps a small keepalive timer so Node.js does not exit prematurely
220
+ when stdin reaches EOF before an async subprocess registers an active handle.
221
+ For Claude and Gemini provider mode, that keepalive is cleared during shutdown:
222
+ the server now exits when the MCP stdio connection closes and kills any tracked
223
+ detached provider child process groups that would otherwise linger.
200
224
 
201
225
  ## License
202
226
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-agents",
3
- "version": "0.5.8",
3
+ "version": "0.6.5",
4
4
  "description": "MCP server that wraps AI CLI tools (Claude Code, Gemini CLI, Codex CLI) for use by any MCP client",
5
5
  "type": "module",
6
6
  "bin": {
package/server.js CHANGED
@@ -2,7 +2,15 @@
2
2
  /* eslint-disable no-console */
3
3
 
4
4
  import { spawn } from "node:child_process";
5
- import { readFileSync } from "node:fs";
5
+ import {
6
+ copyFileSync,
7
+ existsSync,
8
+ mkdtempSync,
9
+ readFileSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { tmpdir } from "node:os";
6
14
  import { dirname, join } from "node:path";
7
15
  import { fileURLToPath } from "node:url";
8
16
 
@@ -19,8 +27,13 @@ const VERSION = JSON.parse(
19
27
  ).version;
20
28
 
21
29
  const DEFAULT_TIMEOUT_MS = 300_000;
30
+ const DEFAULT_CODEX_MODEL = "gpt-5.4";
31
+ const DEFAULT_CODEX_MODEL_REASONING_EFFORT = "xhigh";
22
32
  const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
23
33
  const CLAUDE_EMPTY_OUTPUT_MAX_ATTEMPTS = 2;
34
+ const SIGNAL_CODES = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
35
+ const SHUTDOWN_TIMEOUT_MS = 3_000;
36
+ let fatalShutdown;
24
37
 
25
38
  // ---------------------------------------------------------------------------
26
39
  // CLI Backend Definitions
@@ -111,8 +124,8 @@ Usage: mcp-agents [options]
111
124
 
112
125
  Options:
113
126
  --provider <name> CLI backend to use (${providers}) [default: codex]
114
- --model <model> Codex model [default: gpt-5.4]
115
- --model_reasoning_effort <e> Codex reasoning effort [default: high]
127
+ --model <model> Codex model [default: ${DEFAULT_CODEX_MODEL}]
128
+ --model_reasoning_effort <e> Codex reasoning effort [default: ${DEFAULT_CODEX_MODEL_REASONING_EFFORT}]
116
129
  --timeout <seconds> Default timeout per call [default: 300]
117
130
  --help, -h Show this help message
118
131
  --version, -v Show version number`);
@@ -193,12 +206,19 @@ function parseArgs() {
193
206
  * on timeout — prevents orphan child processes.
194
207
  * @param {string} command
195
208
  * @param {string[]} args
196
- * @param {{ timeoutMs?: number, stdinData?: string }} [opts]
209
+ * @param {{
210
+ * timeoutMs?: number,
211
+ * stdinData?: string,
212
+ * onSpawn?: (childInfo: { pid?: number, killGroup: () => void }) => void,
213
+ * onSettled?: (pid?: number) => void,
214
+ * }} [opts]
197
215
  * @returns {Promise<{ output: string, stdoutBytes: number, stderrBytes: number, durationMs: number }>}
198
216
  */
199
217
  function runCli(command, args, opts = {}) {
200
218
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
201
219
  const stdinData = opts.stdinData;
220
+ const onSpawn = opts.onSpawn;
221
+ const onSettled = opts.onSettled;
202
222
  const startedAt = Date.now();
203
223
 
204
224
  return new Promise((resolve, reject) => {
@@ -225,11 +245,13 @@ function runCli(command, args, opts = {}) {
225
245
  const killGroup = () => {
226
246
  try { process.kill(-child.pid, "SIGKILL"); } catch {}
227
247
  };
248
+ onSpawn?.({ pid: child.pid, killGroup });
228
249
 
229
250
  const done = (err) => {
230
251
  clearTimeout(timer);
231
252
  if (settled) return;
232
253
  settled = true;
254
+ onSettled?.(child.pid);
233
255
  err ? reject(err) : resolve({
234
256
  output: (stdout || stderr || "").trimEnd(),
235
257
  stdoutBytes: stdoutLen,
@@ -262,6 +284,7 @@ function runCli(command, args, opts = {}) {
262
284
  const timer = setTimeout(() => {
263
285
  killGroup();
264
286
  }, timeoutMs);
287
+ timer.unref();
265
288
 
266
289
  child.on("error", (err) => {
267
290
  done(new Error(`Failed to start ${command}: ${err.message}`));
@@ -284,22 +307,108 @@ function runCli(command, args, opts = {}) {
284
307
  });
285
308
  }
286
309
 
310
+ /**
311
+ * Resolve the source Codex home used by the parent process.
312
+ * @returns {string}
313
+ */
314
+ function resolveCodexHome() {
315
+ return process.env.CODEX_HOME || join(process.env.HOME || tmpdir(), ".codex");
316
+ }
317
+
318
+ /**
319
+ * Quote a string for TOML output.
320
+ * @param {string} value
321
+ * @returns {string}
322
+ */
323
+ function toTomlString(value) {
324
+ return JSON.stringify(value);
325
+ }
326
+
327
+ /**
328
+ * Build the minimal config for the isolated Codex bridge runtime.
329
+ * @param {{ model: string, modelReasoningEffort: string }} opts
330
+ * @returns {string}
331
+ */
332
+ function buildCodexBridgeConfig({ model, modelReasoningEffort }) {
333
+ return [
334
+ `model = ${toTomlString(model)}`,
335
+ `model_reasoning_effort = ${toTomlString(modelReasoningEffort)}`,
336
+ 'approval_policy = "never"',
337
+ 'sandbox_mode = "read-only"',
338
+ "",
339
+ "[features]",
340
+ "multi_agent = false",
341
+ "",
342
+ ].join("\n");
343
+ }
344
+
345
+ /**
346
+ * Create an isolated Codex home that preserves auth but strips inherited MCP servers.
347
+ * @param {{ model: string, modelReasoningEffort: string }} opts
348
+ * @returns {string}
349
+ */
350
+ function createIsolatedCodexHome({ model, modelReasoningEffort }) {
351
+ const codexHome = mkdtempSync(join(tmpdir(), "mcp-agents-codex-"));
352
+ const sourceAuthPath = join(resolveCodexHome(), "auth.json");
353
+ const targetAuthPath = join(codexHome, "auth.json");
354
+ const configPath = join(codexHome, "config.toml");
355
+
356
+ if (existsSync(sourceAuthPath)) {
357
+ copyFileSync(sourceAuthPath, targetAuthPath);
358
+ }
359
+
360
+ writeFileSync(
361
+ configPath,
362
+ buildCodexBridgeConfig({ model, modelReasoningEffort }),
363
+ "utf8",
364
+ );
365
+
366
+ return codexHome;
367
+ }
368
+
287
369
  /**
288
370
  * Spawn codex mcp-server as a pass-through, piping stdio directly.
289
371
  * @param {{ model?: string, modelReasoningEffort?: string }} opts
290
372
  */
291
373
  function runCodexPassthrough({ model, modelReasoningEffort }) {
292
- const args = [
293
- "mcp-server",
294
- "-c", `model=${model || "gpt-5.4"}`,
295
- "-c", "sandbox_mode=read-only",
296
- "-c", "approval_policy=never",
297
- "-c", `model_reasoning_effort=${modelReasoningEffort || "high"}`,
298
- ];
374
+ const resolvedModel = model || DEFAULT_CODEX_MODEL;
375
+ const resolvedModelReasoningEffort =
376
+ modelReasoningEffort || DEFAULT_CODEX_MODEL_REASONING_EFFORT;
377
+ let isolatedCodexHome;
299
378
 
300
- logErr(`[mcp-agents] passthrough: codex ${args.join(" ")}`);
379
+ try {
380
+ isolatedCodexHome = createIsolatedCodexHome({
381
+ model: resolvedModel,
382
+ modelReasoningEffort: resolvedModelReasoningEffort,
383
+ });
384
+ } catch (err) {
385
+ const msg = err instanceof Error ? err.message : String(err);
386
+ logErr(`[mcp-agents] failed to prepare isolated codex home: ${msg}`);
387
+ process.exitCode = 1;
388
+ return;
389
+ }
390
+
391
+ const args = ["mcp-server"];
392
+ let cleanedUp = false;
393
+ const cleanupIsolatedCodexHome = () => {
394
+ if (cleanedUp || !isolatedCodexHome) return;
395
+ cleanedUp = true;
396
+
397
+ try {
398
+ rmSync(isolatedCodexHome, { recursive: true, force: true });
399
+ } catch (err) {
400
+ const msg = err instanceof Error ? err.message : String(err);
401
+ logErr(`[mcp-agents] failed to clean isolated codex home: ${msg}`);
402
+ }
403
+ };
404
+
405
+ logErr(
406
+ `[mcp-agents] passthrough: codex ${args.join(" ")} ` +
407
+ `(model=${resolvedModel}, reasoning_effort=${resolvedModelReasoningEffort}, isolated_home=true)`,
408
+ );
301
409
 
302
410
  const child = spawn("codex", args, {
411
+ env: { ...process.env, CODEX_HOME: isolatedCodexHome },
303
412
  stdio: ["inherit", "inherit", "pipe"],
304
413
  });
305
414
 
@@ -307,23 +416,25 @@ function runCodexPassthrough({ model, modelReasoningEffort }) {
307
416
  logErr(`[codex] ${chunk.toString().trimEnd()}`);
308
417
  });
309
418
 
310
- const SIGNAL_CODES = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
311
419
  for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
312
420
  process.once(sig, () => {
313
421
  child.kill(sig);
314
422
  setTimeout(() => {
315
423
  child.kill("SIGKILL");
424
+ cleanupIsolatedCodexHome();
316
425
  process.exit(128 + SIGNAL_CODES[sig]);
317
426
  }, 5000).unref();
318
427
  });
319
428
  }
320
429
 
321
430
  child.on("error", (err) => {
431
+ cleanupIsolatedCodexHome();
322
432
  logErr(`[mcp-agents] failed to start codex: ${err.message}`);
323
433
  process.exitCode = 1;
324
434
  });
325
435
 
326
436
  child.on("exit", (code, signal) => {
437
+ cleanupIsolatedCodexHome();
327
438
  if (signal) {
328
439
  logErr(`[mcp-agents] codex killed by ${signal}`);
329
440
  process.exitCode = 128 + (SIGNAL_CODES[signal] ?? 0);
@@ -358,6 +469,53 @@ async function main() {
358
469
  { name: "mcp-agents", version: VERSION },
359
470
  { capabilities: { tools: {} } },
360
471
  );
472
+ let keepAlive;
473
+ let shutdownStarted = false;
474
+ let shutdownExitCode = 0;
475
+ let shutdownPromise;
476
+ let shutdownTimer;
477
+ let activeRequests = 0;
478
+ const activeChildren = new Map();
479
+
480
+ const maybeFinalizeShutdown = () => {
481
+ if (!shutdownStarted || activeRequests > 0 || shutdownPromise) return;
482
+
483
+ shutdownPromise = Promise.resolve()
484
+ .then(async () => {
485
+ if (keepAlive) clearInterval(keepAlive);
486
+ await server.close();
487
+ })
488
+ .catch((err) => {
489
+ const msg = err instanceof Error ? err.message : String(err);
490
+ logErr(`[mcp-agents] shutdown close failed: ${msg}`);
491
+ })
492
+ .finally(() => {
493
+ if (shutdownTimer) clearTimeout(shutdownTimer);
494
+ process.exit(shutdownExitCode);
495
+ });
496
+ };
497
+
498
+ const beginShutdown = (reason, exitCode = 0) => {
499
+ if (shutdownStarted) return;
500
+
501
+ shutdownStarted = true;
502
+ shutdownExitCode = exitCode;
503
+ logErr(
504
+ `[mcp-agents] shutting down (provider=${providerName}, reason=${reason})`,
505
+ );
506
+
507
+ shutdownTimer = setTimeout(() => {
508
+ process.exit(shutdownExitCode);
509
+ }, SHUTDOWN_TIMEOUT_MS);
510
+ shutdownTimer.unref();
511
+
512
+ for (const killGroup of activeChildren.values()) {
513
+ killGroup();
514
+ }
515
+
516
+ maybeFinalizeShutdown();
517
+ };
518
+ fatalShutdown = beginShutdown;
361
519
 
362
520
  const effectiveTimeout = defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
363
521
 
@@ -404,6 +562,13 @@ async function main() {
404
562
  return { content: [{ type: "text", text: "pong" }] };
405
563
  }
406
564
 
565
+ if (shutdownStarted) {
566
+ return {
567
+ content: [{ type: "text", text: "Server is shutting down" }],
568
+ isError: true,
569
+ };
570
+ }
571
+
407
572
  if (params.name !== backend.toolName) {
408
573
  return {
409
574
  content: [
@@ -416,6 +581,7 @@ async function main() {
416
581
  };
417
582
  }
418
583
 
584
+ activeRequests += 1;
419
585
  const rawArgs =
420
586
  params.arguments && typeof params.arguments === "object"
421
587
  ? params.arguments
@@ -441,6 +607,8 @@ async function main() {
441
607
  : effectiveTimeout;
442
608
 
443
609
  if (!prompt.trim()) {
610
+ activeRequests -= 1;
611
+ maybeFinalizeShutdown();
444
612
  return {
445
613
  content: [
446
614
  {
@@ -461,13 +629,30 @@ async function main() {
461
629
  ? backend.buildArgs(extraOpts)
462
630
  : backend.buildArgs(prompt, extraOpts);
463
631
  const buildCliOpts = (attemptTimeoutMs) => (
464
- backend.stdinPrompt
465
- ? { timeoutMs: attemptTimeoutMs, stdinData: prompt }
466
- : { timeoutMs: attemptTimeoutMs }
632
+ {
633
+ timeoutMs: attemptTimeoutMs,
634
+ ...(backend.stdinPrompt ? { stdinData: prompt } : {}),
635
+ onSpawn: ({ pid, killGroup }) => {
636
+ if (!pid) return;
637
+ activeChildren.set(pid, killGroup);
638
+ },
639
+ onSettled: (pid) => {
640
+ if (!pid) return;
641
+ activeChildren.delete(pid);
642
+ maybeFinalizeShutdown();
643
+ },
644
+ }
467
645
  );
468
646
 
469
647
  logErr(`[mcp-agents] tools/call: running ${backend.command} …`);
470
648
  try {
649
+ if (shutdownStarted) {
650
+ return {
651
+ content: [{ type: "text", text: "Server is shutting down" }],
652
+ isError: true,
653
+ };
654
+ }
655
+
471
656
  const startedAt = Date.now();
472
657
  const maxAttempts = providerName === "claude"
473
658
  ? CLAUDE_EMPTY_OUTPUT_MAX_ATTEMPTS
@@ -552,6 +737,9 @@ async function main() {
552
737
  content: [{ type: "text", text: msg }],
553
738
  isError: true,
554
739
  };
740
+ } finally {
741
+ activeRequests -= 1;
742
+ maybeFinalizeShutdown();
555
743
  }
556
744
  });
557
745
 
@@ -562,13 +750,28 @@ async function main() {
562
750
  // request handlers (tools/call -> execFile) register active handles.
563
751
  // The SDK transport doesn't listen for stdin 'end', so the event
564
752
  // loop loses its only handle when the pipe closes.
565
- const keepAlive = setInterval(() => {}, 60_000);
753
+ keepAlive = setInterval(() => {}, 60_000);
566
754
  const origOnClose = transport.onclose;
567
755
  transport.onclose = () => {
568
756
  clearInterval(keepAlive);
569
757
  origOnClose?.();
570
758
  };
571
759
 
760
+ process.stdin.once("end", () => {
761
+ beginShutdown("stdin-end");
762
+ });
763
+ process.stdin.once("close", () => {
764
+ beginShutdown("stdin-close");
765
+ });
766
+ process.stdout.on("error", (err) => {
767
+ if (err?.code === "EPIPE") beginShutdown("stdout-epipe");
768
+ });
769
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
770
+ process.once(sig, () => {
771
+ beginShutdown(sig, 128 + SIGNAL_CODES[sig]);
772
+ });
773
+ }
774
+
572
775
  logErr(`[mcp-agents] ready (provider: ${providerName})`);
573
776
  }
574
777
 
@@ -576,15 +779,23 @@ process.on("unhandledRejection", (reason) => {
576
779
  logErr(
577
780
  `UnhandledRejection: ${reason instanceof Error ? reason.stack : reason}`,
578
781
  );
579
- process.exitCode = 1;
782
+ if (fatalShutdown) {
783
+ fatalShutdown("unhandledRejection", 1);
784
+ return;
785
+ }
786
+ process.exit(1);
580
787
  });
581
788
 
582
789
  process.on("uncaughtException", (err) => {
583
790
  logErr(`UncaughtException: ${err.stack || err.message}`);
584
- process.exitCode = 1;
791
+ if (fatalShutdown) {
792
+ fatalShutdown("uncaughtException", 1);
793
+ return;
794
+ }
795
+ process.exit(1);
585
796
  });
586
797
 
587
798
  main().catch((err) => {
588
799
  logErr(err.stack || err.message);
589
- process.exitCode = 1;
800
+ process.exit(1);
590
801
  });