mcp-agents 0.5.7 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +5 -1
  2. package/package.json +1 -1
  3. package/server.js +128 -40
package/README.md CHANGED
@@ -196,7 +196,11 @@ After `npm link`, any edits to `server.js` take effect immediately — no reinst
196
196
  4. Client calls `tools/call` with the tool name and a `prompt`
197
197
  5. The server runs the CLI as a child process and returns tool text (Claude JSON `result`, or stdout/stderr for other providers)
198
198
 
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.
199
+ The server keeps a small keepalive timer so Node.js does not exit prematurely
200
+ when stdin reaches EOF before an async subprocess registers an active handle.
201
+ For Claude and Gemini provider mode, that keepalive is cleared during shutdown:
202
+ the server now exits when the MCP stdio connection closes and kills any tracked
203
+ detached provider child process groups that would otherwise linger.
200
204
 
201
205
  ## License
202
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-agents",
3
- "version": "0.5.7",
3
+ "version": "0.6.0",
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
@@ -21,6 +21,9 @@ const VERSION = JSON.parse(
21
21
  const DEFAULT_TIMEOUT_MS = 300_000;
22
22
  const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
23
23
  const CLAUDE_EMPTY_OUTPUT_MAX_ATTEMPTS = 2;
24
+ const SIGNAL_CODES = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
25
+ const SHUTDOWN_TIMEOUT_MS = 3_000;
26
+ let fatalShutdown;
24
27
 
25
28
  // ---------------------------------------------------------------------------
26
29
  // CLI Backend Definitions
@@ -40,21 +43,10 @@ const CLI_BACKENDS = {
40
43
  command: "gemini",
41
44
  toolName: "gemini",
42
45
  description:
43
- "Run Gemini CLI (gemini -p) with a prompt. Supports prompt + optional timeout_ms/sandbox only; other arguments are ignored.",
46
+ "Run Gemini CLI with a prompt. Always runs in sandbox mode with --approval-mode=plan.",
44
47
  stdinPrompt: false,
45
- buildArgs: (prompt, opts) => {
46
- const args = [];
47
- if (opts.sandbox === true) args.push("-s");
48
- args.push("-p", prompt);
49
- return args;
50
- },
51
- extraProperties: {
52
- sandbox: {
53
- type: "boolean",
54
- default: false,
55
- description: "Run in sandbox mode (-s flag). Defaults to false.",
56
- },
57
- },
48
+ buildArgs: (prompt) => ["-s", "--approval-mode=plan", "-p", prompt],
49
+ extraProperties: {},
58
50
  },
59
51
  codex: {
60
52
  passthrough: true,
@@ -124,7 +116,6 @@ Options:
124
116
  --provider <name> CLI backend to use (${providers}) [default: codex]
125
117
  --model <model> Codex model [default: gpt-5.4]
126
118
  --model_reasoning_effort <e> Codex reasoning effort [default: high]
127
- --sandbox <bool> Gemini sandbox mode (true/false) [default: false]
128
119
  --timeout <seconds> Default timeout per call [default: 300]
129
120
  --help, -h Show this help message
130
121
  --version, -v Show version number`);
@@ -132,15 +123,14 @@ Options:
132
123
 
133
124
  /**
134
125
  * Parse CLI flags from process.argv.
135
- * Handles --help, --version, --provider, --model, --model_reasoning_effort, --sandbox, and unknown flags.
136
- * @returns {{ provider: string, model?: string, modelReasoningEffort?: string, sandbox: boolean, defaultTimeoutMs?: number }}
126
+ * Handles --help, --version, --provider, --model, --model_reasoning_effort, and unknown flags.
127
+ * @returns {{ provider: string, model?: string, modelReasoningEffort?: string, defaultTimeoutMs?: number }}
137
128
  */
138
129
  function parseArgs() {
139
130
  const args = process.argv.slice(2);
140
131
  let provider = "codex";
141
132
  let model;
142
133
  let modelReasoningEffort;
143
- let sandbox = false;
144
134
  let defaultTimeoutMs;
145
135
 
146
136
  for (let i = 0; i < args.length; i++) {
@@ -178,13 +168,6 @@ function parseArgs() {
178
168
  }
179
169
  modelReasoningEffort = args[++i];
180
170
  break;
181
- case "--sandbox":
182
- if (i + 1 >= args.length) {
183
- process.stderr.write("error: --sandbox requires a value\n");
184
- process.exit(1);
185
- }
186
- sandbox = args[++i] === "true";
187
- break;
188
171
  case "--timeout": {
189
172
  if (i + 1 >= args.length) {
190
173
  process.stderr.write("error: --timeout requires a value\n");
@@ -204,7 +187,7 @@ function parseArgs() {
204
187
  }
205
188
  }
206
189
 
207
- return { provider, model, modelReasoningEffort, sandbox, defaultTimeoutMs };
190
+ return { provider, model, modelReasoningEffort, defaultTimeoutMs };
208
191
  }
209
192
 
210
193
  /**
@@ -213,12 +196,19 @@ function parseArgs() {
213
196
  * on timeout — prevents orphan child processes.
214
197
  * @param {string} command
215
198
  * @param {string[]} args
216
- * @param {{ timeoutMs?: number, stdinData?: string }} [opts]
199
+ * @param {{
200
+ * timeoutMs?: number,
201
+ * stdinData?: string,
202
+ * onSpawn?: (childInfo: { pid?: number, killGroup: () => void }) => void,
203
+ * onSettled?: (pid?: number) => void,
204
+ * }} [opts]
217
205
  * @returns {Promise<{ output: string, stdoutBytes: number, stderrBytes: number, durationMs: number }>}
218
206
  */
219
207
  function runCli(command, args, opts = {}) {
220
208
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
221
209
  const stdinData = opts.stdinData;
210
+ const onSpawn = opts.onSpawn;
211
+ const onSettled = opts.onSettled;
222
212
  const startedAt = Date.now();
223
213
 
224
214
  return new Promise((resolve, reject) => {
@@ -245,11 +235,13 @@ function runCli(command, args, opts = {}) {
245
235
  const killGroup = () => {
246
236
  try { process.kill(-child.pid, "SIGKILL"); } catch {}
247
237
  };
238
+ onSpawn?.({ pid: child.pid, killGroup });
248
239
 
249
240
  const done = (err) => {
250
241
  clearTimeout(timer);
251
242
  if (settled) return;
252
243
  settled = true;
244
+ onSettled?.(child.pid);
253
245
  err ? reject(err) : resolve({
254
246
  output: (stdout || stderr || "").trimEnd(),
255
247
  stdoutBytes: stdoutLen,
@@ -282,6 +274,7 @@ function runCli(command, args, opts = {}) {
282
274
  const timer = setTimeout(() => {
283
275
  killGroup();
284
276
  }, timeoutMs);
277
+ timer.unref();
285
278
 
286
279
  child.on("error", (err) => {
287
280
  done(new Error(`Failed to start ${command}: ${err.message}`));
@@ -327,7 +320,6 @@ function runCodexPassthrough({ model, modelReasoningEffort }) {
327
320
  logErr(`[codex] ${chunk.toString().trimEnd()}`);
328
321
  });
329
322
 
330
- const SIGNAL_CODES = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
331
323
  for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
332
324
  process.once(sig, () => {
333
325
  child.kill(sig);
@@ -359,7 +351,7 @@ function runCodexPassthrough({ model, modelReasoningEffort }) {
359
351
  // ---------------------------------------------------------------------------
360
352
 
361
353
  async function main() {
362
- const { provider: providerName, model, modelReasoningEffort, sandbox, defaultTimeoutMs } = parseArgs();
354
+ const { provider: providerName, model, modelReasoningEffort, defaultTimeoutMs } = parseArgs();
363
355
  const backend = CLI_BACKENDS[providerName];
364
356
 
365
357
  if (!backend) {
@@ -374,14 +366,57 @@ async function main() {
374
366
  return;
375
367
  }
376
368
 
377
- if (backend.extraProperties.sandbox) {
378
- backend.extraProperties.sandbox.default = sandbox;
379
- }
380
-
381
369
  const server = new Server(
382
370
  { name: "mcp-agents", version: VERSION },
383
371
  { capabilities: { tools: {} } },
384
372
  );
373
+ let keepAlive;
374
+ let shutdownStarted = false;
375
+ let shutdownExitCode = 0;
376
+ let shutdownPromise;
377
+ let shutdownTimer;
378
+ let activeRequests = 0;
379
+ const activeChildren = new Map();
380
+
381
+ const maybeFinalizeShutdown = () => {
382
+ if (!shutdownStarted || activeRequests > 0 || shutdownPromise) return;
383
+
384
+ shutdownPromise = Promise.resolve()
385
+ .then(async () => {
386
+ if (keepAlive) clearInterval(keepAlive);
387
+ await server.close();
388
+ })
389
+ .catch((err) => {
390
+ const msg = err instanceof Error ? err.message : String(err);
391
+ logErr(`[mcp-agents] shutdown close failed: ${msg}`);
392
+ })
393
+ .finally(() => {
394
+ if (shutdownTimer) clearTimeout(shutdownTimer);
395
+ process.exit(shutdownExitCode);
396
+ });
397
+ };
398
+
399
+ const beginShutdown = (reason, exitCode = 0) => {
400
+ if (shutdownStarted) return;
401
+
402
+ shutdownStarted = true;
403
+ shutdownExitCode = exitCode;
404
+ logErr(
405
+ `[mcp-agents] shutting down (provider=${providerName}, reason=${reason})`,
406
+ );
407
+
408
+ shutdownTimer = setTimeout(() => {
409
+ process.exit(shutdownExitCode);
410
+ }, SHUTDOWN_TIMEOUT_MS);
411
+ shutdownTimer.unref();
412
+
413
+ for (const killGroup of activeChildren.values()) {
414
+ killGroup();
415
+ }
416
+
417
+ maybeFinalizeShutdown();
418
+ };
419
+ fatalShutdown = beginShutdown;
385
420
 
386
421
  const effectiveTimeout = defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
387
422
 
@@ -428,6 +463,13 @@ async function main() {
428
463
  return { content: [{ type: "text", text: "pong" }] };
429
464
  }
430
465
 
466
+ if (shutdownStarted) {
467
+ return {
468
+ content: [{ type: "text", text: "Server is shutting down" }],
469
+ isError: true,
470
+ };
471
+ }
472
+
431
473
  if (params.name !== backend.toolName) {
432
474
  return {
433
475
  content: [
@@ -440,6 +482,7 @@ async function main() {
440
482
  };
441
483
  }
442
484
 
485
+ activeRequests += 1;
443
486
  const rawArgs =
444
487
  params.arguments && typeof params.arguments === "object"
445
488
  ? params.arguments
@@ -465,6 +508,8 @@ async function main() {
465
508
  : effectiveTimeout;
466
509
 
467
510
  if (!prompt.trim()) {
511
+ activeRequests -= 1;
512
+ maybeFinalizeShutdown();
468
513
  return {
469
514
  content: [
470
515
  {
@@ -485,13 +530,30 @@ async function main() {
485
530
  ? backend.buildArgs(extraOpts)
486
531
  : backend.buildArgs(prompt, extraOpts);
487
532
  const buildCliOpts = (attemptTimeoutMs) => (
488
- backend.stdinPrompt
489
- ? { timeoutMs: attemptTimeoutMs, stdinData: prompt }
490
- : { timeoutMs: attemptTimeoutMs }
533
+ {
534
+ timeoutMs: attemptTimeoutMs,
535
+ ...(backend.stdinPrompt ? { stdinData: prompt } : {}),
536
+ onSpawn: ({ pid, killGroup }) => {
537
+ if (!pid) return;
538
+ activeChildren.set(pid, killGroup);
539
+ },
540
+ onSettled: (pid) => {
541
+ if (!pid) return;
542
+ activeChildren.delete(pid);
543
+ maybeFinalizeShutdown();
544
+ },
545
+ }
491
546
  );
492
547
 
493
548
  logErr(`[mcp-agents] tools/call: running ${backend.command} …`);
494
549
  try {
550
+ if (shutdownStarted) {
551
+ return {
552
+ content: [{ type: "text", text: "Server is shutting down" }],
553
+ isError: true,
554
+ };
555
+ }
556
+
495
557
  const startedAt = Date.now();
496
558
  const maxAttempts = providerName === "claude"
497
559
  ? CLAUDE_EMPTY_OUTPUT_MAX_ATTEMPTS
@@ -576,6 +638,9 @@ async function main() {
576
638
  content: [{ type: "text", text: msg }],
577
639
  isError: true,
578
640
  };
641
+ } finally {
642
+ activeRequests -= 1;
643
+ maybeFinalizeShutdown();
579
644
  }
580
645
  });
581
646
 
@@ -586,13 +651,28 @@ async function main() {
586
651
  // request handlers (tools/call -> execFile) register active handles.
587
652
  // The SDK transport doesn't listen for stdin 'end', so the event
588
653
  // loop loses its only handle when the pipe closes.
589
- const keepAlive = setInterval(() => {}, 60_000);
654
+ keepAlive = setInterval(() => {}, 60_000);
590
655
  const origOnClose = transport.onclose;
591
656
  transport.onclose = () => {
592
657
  clearInterval(keepAlive);
593
658
  origOnClose?.();
594
659
  };
595
660
 
661
+ process.stdin.once("end", () => {
662
+ beginShutdown("stdin-end");
663
+ });
664
+ process.stdin.once("close", () => {
665
+ beginShutdown("stdin-close");
666
+ });
667
+ process.stdout.on("error", (err) => {
668
+ if (err?.code === "EPIPE") beginShutdown("stdout-epipe");
669
+ });
670
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
671
+ process.once(sig, () => {
672
+ beginShutdown(sig, 128 + SIGNAL_CODES[sig]);
673
+ });
674
+ }
675
+
596
676
  logErr(`[mcp-agents] ready (provider: ${providerName})`);
597
677
  }
598
678
 
@@ -600,15 +680,23 @@ process.on("unhandledRejection", (reason) => {
600
680
  logErr(
601
681
  `UnhandledRejection: ${reason instanceof Error ? reason.stack : reason}`,
602
682
  );
603
- process.exitCode = 1;
683
+ if (fatalShutdown) {
684
+ fatalShutdown("unhandledRejection", 1);
685
+ return;
686
+ }
687
+ process.exit(1);
604
688
  });
605
689
 
606
690
  process.on("uncaughtException", (err) => {
607
691
  logErr(`UncaughtException: ${err.stack || err.message}`);
608
- process.exitCode = 1;
692
+ if (fatalShutdown) {
693
+ fatalShutdown("uncaughtException", 1);
694
+ return;
695
+ }
696
+ process.exit(1);
609
697
  });
610
698
 
611
699
  main().catch((err) => {
612
700
  logErr(err.stack || err.message);
613
- process.exitCode = 1;
701
+ process.exit(1);
614
702
  });