@evantahler/mcpx 0.21.5 → 0.21.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evantahler/mcpx",
3
- "version": "0.21.5",
3
+ "version": "0.21.6",
4
4
  "description": "A command-line interface for MCP servers. curl for MCP.",
5
5
  "type": "module",
6
6
  "exports": {
package/src/cli.ts CHANGED
@@ -22,8 +22,11 @@ import { registerSkillCommand } from "./commands/skill.ts";
22
22
  import { registerTaskCommand } from "./commands/task.ts";
23
23
  import { registerUpgradeCommand } from "./commands/upgrade.ts";
24
24
  import { logger } from "./output/logger.ts";
25
+ import { ExitError, installSignalHandlers } from "./shutdown.ts";
25
26
  import { maybeCheckForUpdate } from "./update/background.ts";
26
27
 
28
+ installSignalHandlers();
29
+
27
30
  program
28
31
  .name("mcpx")
29
32
  .description("A command-line interface for MCP servers. curl for MCP.")
@@ -92,7 +95,15 @@ if (firstCommand && !knownCommands.has(firstCommand)) {
92
95
  // Fire-and-forget background update check
93
96
  const updateNotice = maybeCheckForUpdate();
94
97
 
95
- program.parse();
98
+ try {
99
+ await program.parseAsync();
100
+ } catch (err) {
101
+ if (err instanceof ExitError) {
102
+ process.exit(err.code);
103
+ }
104
+ logger.error(String(err));
105
+ process.exit(1);
106
+ }
96
107
 
97
108
  // Print update notice after command output completes
98
109
  process.on("beforeExit", async () => {
@@ -20,6 +20,7 @@ import pkg from "../../package.json";
20
20
  import type { AuthFile, Prompt, Resource, ServerConfig, ServersFile, Tool } from "../config/schemas.ts";
21
21
  import { isHttpServer, isStdioServer } from "../config/schemas.ts";
22
22
  import { logger } from "../output/logger.ts";
23
+ import { register, unregister } from "../shutdown.ts";
23
24
  import { handleElicitation } from "./elicitation.ts";
24
25
  import { createHttpTransport } from "./http.ts";
25
26
  import { McpOAuthProvider } from "./oauth.ts";
@@ -97,6 +98,7 @@ export class ServerManager {
97
98
  this.logLevel = opts.logLevel ?? "warning";
98
99
  this.json = opts.json ?? false;
99
100
  this.noInteractive = opts.noInteractive ?? false;
101
+ register(this);
100
102
  }
101
103
 
102
104
  /** Get or create a connected client for a server */
@@ -492,16 +494,19 @@ export class ServerManager {
492
494
 
493
495
  /** Disconnect all clients */
494
496
  async close(): Promise<void> {
495
- const closePromises = [...this.clients.entries()].map(async ([name, client]) => {
497
+ unregister(this);
498
+ // Close every transport — covers in-flight connects whose client hasn't been promoted
499
+ // into `this.clients` yet (otherwise stdio children would be orphaned on shutdown).
500
+ const closePromises = [...this.transports.values()].map(async (transport) => {
496
501
  try {
497
- await client.close();
502
+ await transport.close?.();
498
503
  } catch {
499
504
  // Ignore close errors
500
505
  }
501
- this.clients.delete(name);
502
- this.transports.delete(name);
503
506
  });
504
507
  await Promise.allSettled(closePromises);
508
+ this.clients.clear();
509
+ this.transports.clear();
505
510
  this.connecting.clear();
506
511
  }
507
512
  }
@@ -13,6 +13,7 @@ import {
13
13
  formatValidationErrors,
14
14
  } from "../output/formatter.ts";
15
15
  import { logger } from "../output/logger.ts";
16
+ import { ExitError } from "../shutdown.ts";
16
17
  import { validateToolInput } from "../validation/schema.ts";
17
18
 
18
19
  type ResolvedArgs =
@@ -268,10 +269,10 @@ export function registerExecCommand(program: Command) {
268
269
  for (const elicitation of err.elicitations) {
269
270
  await handleUrlElicitation(elicitation, elicitOptions);
270
271
  }
271
- process.exit(1);
272
+ throw new ExitError(1);
272
273
  }
273
274
  console.error(formatError(String(err), formatOptions));
274
- process.exit(1);
275
+ throw new ExitError(1);
275
276
  } finally {
276
277
  await manager.close();
277
278
  }
@@ -2,6 +2,7 @@ import type { Command } from "commander";
2
2
  import { type AppContext, getContext } from "../context.ts";
3
3
  import { formatError } from "../output/formatter.ts";
4
4
  import { logger, type Spinner } from "../output/logger.ts";
5
+ import { ExitError } from "../shutdown.ts";
5
6
 
6
7
  export interface CommandContext extends AppContext {
7
8
  spinner: Spinner;
@@ -47,9 +48,10 @@ export function withCommand<TArgs extends unknown[]>(
47
48
  try {
48
49
  await handler({ ...appCtx, spinner }, ...args);
49
50
  } catch (err) {
51
+ if (err instanceof ExitError) throw err;
50
52
  spinner.error(options.errorLabel ?? "Failed");
51
53
  console.error(formatError(String(err), formatOptions));
52
- process.exit(1);
54
+ throw new ExitError(1);
53
55
  } finally {
54
56
  await manager.close();
55
57
  }
@@ -0,0 +1,65 @@
1
+ import { logger } from "./output/logger.ts";
2
+
3
+ export interface Closeable {
4
+ close(): Promise<void>;
5
+ }
6
+
7
+ export class ExitError extends Error {
8
+ constructor(
9
+ public readonly code: number,
10
+ message?: string,
11
+ ) {
12
+ super(message);
13
+ this.name = "ExitError";
14
+ }
15
+ }
16
+
17
+ const registry = new Set<Closeable>();
18
+ let shuttingDown = false;
19
+
20
+ export function register(c: Closeable): void {
21
+ registry.add(c);
22
+ }
23
+
24
+ export function unregister(c: Closeable): void {
25
+ registry.delete(c);
26
+ }
27
+
28
+ const HARD_TIMEOUT_MS = 8000;
29
+
30
+ async function runShutdown(exitCode: number): Promise<never> {
31
+ if (shuttingDown) {
32
+ process.exit(exitCode);
33
+ }
34
+ shuttingDown = true;
35
+
36
+ const closeAll = Promise.allSettled([...registry].map((c) => c.close()));
37
+ await Promise.race([closeAll, new Promise<void>((resolve) => setTimeout(resolve, HARD_TIMEOUT_MS).unref())]);
38
+ process.exit(exitCode);
39
+ }
40
+
41
+ let installed = false;
42
+
43
+ export function installSignalHandlers(): void {
44
+ if (installed) return;
45
+ installed = true;
46
+
47
+ process.on("SIGINT", () => {
48
+ void runShutdown(130);
49
+ });
50
+ process.on("SIGTERM", () => {
51
+ void runShutdown(143);
52
+ });
53
+ process.on("SIGHUP", () => {
54
+ void runShutdown(129);
55
+ });
56
+ process.on("uncaughtException", (err) => {
57
+ logger.error(`uncaught exception: ${err?.stack ?? String(err)}`);
58
+ void runShutdown(1);
59
+ });
60
+ process.on("unhandledRejection", (reason) => {
61
+ const detail = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
62
+ logger.error(`unhandled rejection: ${detail}`);
63
+ void runShutdown(1);
64
+ });
65
+ }