mcp-agents 0.5.8 → 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 +121 -9
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.8",
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
@@ -193,12 +196,19 @@ function parseArgs() {
193
196
  * on timeout — prevents orphan child processes.
194
197
  * @param {string} command
195
198
  * @param {string[]} args
196
- * @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]
197
205
  * @returns {Promise<{ output: string, stdoutBytes: number, stderrBytes: number, durationMs: number }>}
198
206
  */
199
207
  function runCli(command, args, opts = {}) {
200
208
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
201
209
  const stdinData = opts.stdinData;
210
+ const onSpawn = opts.onSpawn;
211
+ const onSettled = opts.onSettled;
202
212
  const startedAt = Date.now();
203
213
 
204
214
  return new Promise((resolve, reject) => {
@@ -225,11 +235,13 @@ function runCli(command, args, opts = {}) {
225
235
  const killGroup = () => {
226
236
  try { process.kill(-child.pid, "SIGKILL"); } catch {}
227
237
  };
238
+ onSpawn?.({ pid: child.pid, killGroup });
228
239
 
229
240
  const done = (err) => {
230
241
  clearTimeout(timer);
231
242
  if (settled) return;
232
243
  settled = true;
244
+ onSettled?.(child.pid);
233
245
  err ? reject(err) : resolve({
234
246
  output: (stdout || stderr || "").trimEnd(),
235
247
  stdoutBytes: stdoutLen,
@@ -262,6 +274,7 @@ function runCli(command, args, opts = {}) {
262
274
  const timer = setTimeout(() => {
263
275
  killGroup();
264
276
  }, timeoutMs);
277
+ timer.unref();
265
278
 
266
279
  child.on("error", (err) => {
267
280
  done(new Error(`Failed to start ${command}: ${err.message}`));
@@ -307,7 +320,6 @@ function runCodexPassthrough({ model, modelReasoningEffort }) {
307
320
  logErr(`[codex] ${chunk.toString().trimEnd()}`);
308
321
  });
309
322
 
310
- const SIGNAL_CODES = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
311
323
  for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
312
324
  process.once(sig, () => {
313
325
  child.kill(sig);
@@ -358,6 +370,53 @@ async function main() {
358
370
  { name: "mcp-agents", version: VERSION },
359
371
  { capabilities: { tools: {} } },
360
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;
361
420
 
362
421
  const effectiveTimeout = defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
363
422
 
@@ -404,6 +463,13 @@ async function main() {
404
463
  return { content: [{ type: "text", text: "pong" }] };
405
464
  }
406
465
 
466
+ if (shutdownStarted) {
467
+ return {
468
+ content: [{ type: "text", text: "Server is shutting down" }],
469
+ isError: true,
470
+ };
471
+ }
472
+
407
473
  if (params.name !== backend.toolName) {
408
474
  return {
409
475
  content: [
@@ -416,6 +482,7 @@ async function main() {
416
482
  };
417
483
  }
418
484
 
485
+ activeRequests += 1;
419
486
  const rawArgs =
420
487
  params.arguments && typeof params.arguments === "object"
421
488
  ? params.arguments
@@ -441,6 +508,8 @@ async function main() {
441
508
  : effectiveTimeout;
442
509
 
443
510
  if (!prompt.trim()) {
511
+ activeRequests -= 1;
512
+ maybeFinalizeShutdown();
444
513
  return {
445
514
  content: [
446
515
  {
@@ -461,13 +530,30 @@ async function main() {
461
530
  ? backend.buildArgs(extraOpts)
462
531
  : backend.buildArgs(prompt, extraOpts);
463
532
  const buildCliOpts = (attemptTimeoutMs) => (
464
- backend.stdinPrompt
465
- ? { timeoutMs: attemptTimeoutMs, stdinData: prompt }
466
- : { 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
+ }
467
546
  );
468
547
 
469
548
  logErr(`[mcp-agents] tools/call: running ${backend.command} …`);
470
549
  try {
550
+ if (shutdownStarted) {
551
+ return {
552
+ content: [{ type: "text", text: "Server is shutting down" }],
553
+ isError: true,
554
+ };
555
+ }
556
+
471
557
  const startedAt = Date.now();
472
558
  const maxAttempts = providerName === "claude"
473
559
  ? CLAUDE_EMPTY_OUTPUT_MAX_ATTEMPTS
@@ -552,6 +638,9 @@ async function main() {
552
638
  content: [{ type: "text", text: msg }],
553
639
  isError: true,
554
640
  };
641
+ } finally {
642
+ activeRequests -= 1;
643
+ maybeFinalizeShutdown();
555
644
  }
556
645
  });
557
646
 
@@ -562,13 +651,28 @@ async function main() {
562
651
  // request handlers (tools/call -> execFile) register active handles.
563
652
  // The SDK transport doesn't listen for stdin 'end', so the event
564
653
  // loop loses its only handle when the pipe closes.
565
- const keepAlive = setInterval(() => {}, 60_000);
654
+ keepAlive = setInterval(() => {}, 60_000);
566
655
  const origOnClose = transport.onclose;
567
656
  transport.onclose = () => {
568
657
  clearInterval(keepAlive);
569
658
  origOnClose?.();
570
659
  };
571
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
+
572
676
  logErr(`[mcp-agents] ready (provider: ${providerName})`);
573
677
  }
574
678
 
@@ -576,15 +680,23 @@ process.on("unhandledRejection", (reason) => {
576
680
  logErr(
577
681
  `UnhandledRejection: ${reason instanceof Error ? reason.stack : reason}`,
578
682
  );
579
- process.exitCode = 1;
683
+ if (fatalShutdown) {
684
+ fatalShutdown("unhandledRejection", 1);
685
+ return;
686
+ }
687
+ process.exit(1);
580
688
  });
581
689
 
582
690
  process.on("uncaughtException", (err) => {
583
691
  logErr(`UncaughtException: ${err.stack || err.message}`);
584
- process.exitCode = 1;
692
+ if (fatalShutdown) {
693
+ fatalShutdown("uncaughtException", 1);
694
+ return;
695
+ }
696
+ process.exit(1);
585
697
  });
586
698
 
587
699
  main().catch((err) => {
588
700
  logErr(err.stack || err.message);
589
- process.exitCode = 1;
701
+ process.exit(1);
590
702
  });