mcp-compress-router 1.5.0 → 1.5.1

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.
@@ -1,4 +1,5 @@
1
- import { resolveConfigPath, persistAuthRequirements, loadConfig, ServerConnection, invokeWithRecovery, buildCatalog, } from '../services/index.js';
1
+ import process from 'node:process';
2
+ import { resolveConfigPath, persistAuthRequirements, loadConfig, ServerConnection, invokeWithRecovery, buildCatalog, ShutdownCoordinator, installShutdownTriggers, } from '../services/index.js';
2
3
  import { Logger } from '../utils/index.js';
3
4
  import { createGetToolSchemaHandler, buildGetToolSchemaDescription, GetToolSchemaInputSchema, createInvokeToolHandler, InvokeToolInputSchema, } from '../tools/index.js';
4
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -43,6 +44,8 @@ async function connectAllServers(servers, configPath, logger) {
43
44
  * @param connections - Live ServerConnection instances keyed by name.
44
45
  * @param selectionByServer - Per-server tool selection for re-filtering.
45
46
  * @param logger - Structured logger.
47
+ * @returns A cleanup function that closes the MCP server (and its stdio
48
+ * transport) during shutdown.
46
49
  */
47
50
  async function startRouterServer(catalog, connections, selectionByServer, logger) {
48
51
  const router = new McpServer({
@@ -67,6 +70,29 @@ async function startRouterServer(catalog, connections, selectionByServer, logger
67
70
  const transport = new StdioServerTransport();
68
71
  await router.connect(transport);
69
72
  logger.info('Server started on stdio');
73
+ return async () => {
74
+ try {
75
+ await router.close();
76
+ }
77
+ catch {
78
+ // Ignore close errors during shutdown — the process is exiting.
79
+ }
80
+ };
81
+ }
82
+ /**
83
+ * Closes every downstream server connection in parallel. Each
84
+ * `ServerConnection.close()` drives the SDK's graduated kill (end stdin,
85
+ * SIGTERM, then SIGKILL) so spawned downstream servers are terminated
86
+ * rather than orphaned when the router exits.
87
+ *
88
+ * @param connections - Live ServerConnection instances keyed by name.
89
+ * @param logger - Structured logger.
90
+ */
91
+ async function closeAllConnections(connections, logger) {
92
+ logger.info('Closing downstream server connections', {
93
+ servers: [...connections.keys()],
94
+ });
95
+ await Promise.all([...connections.values()].map((conn) => conn.close().catch(() => { })));
70
96
  }
71
97
  /**
72
98
  * Runs the router: loads config, refreshes cached auth requirements,
@@ -108,5 +134,15 @@ export async function runRouter(configPath, verbose) {
108
134
  compressionLevelByServer.set(server.name, server.compressionLevel);
109
135
  }
110
136
  const catalog = buildCatalog(discovered, selectionByServer, logger, compressionLevelByServer);
111
- await startRouterServer(catalog, connections, selectionByServer, logger);
137
+ const closeRouter = await startRouterServer(catalog, connections, selectionByServer, logger);
138
+ const coordinator = new ShutdownCoordinator(logger);
139
+ coordinator.register(() => closeAllConnections(connections, logger));
140
+ coordinator.register(closeRouter);
141
+ installShutdownTriggers(coordinator, logger);
142
+ // Block here until a shutdown trigger fires (signal or stdin EOF), then
143
+ // force-exit so lingering grandchild pipes (e.g. browser processes
144
+ // forked by a downstream server) cannot keep the router alive. Without
145
+ // this, the router would linger as a ghost process forever.
146
+ await coordinator.whenShutdown();
147
+ process.exit(0);
112
148
  }
@@ -2,6 +2,8 @@ export { buildCatalog, lookupTools } from './catalog.js';
2
2
  export { resolveConfigDir, resolveConfigPath, loadConfig } from './config.js';
3
3
  export { discoverSingleServer } from './discovery.js';
4
4
  export { ServerConnection } from './server-connection.js';
5
+ export { ShutdownCoordinator } from './shutdown-coordinator.js';
6
+ export { installShutdownTriggers } from './shutdown-triggers.js';
5
7
  export { invokeWithRecovery } from './invoke-with-recovery.js';
6
8
  export { saveToolCache } from './tool-cache.js';
7
9
  export { OAuthCredentialManager } from './oauth.js';
@@ -0,0 +1,120 @@
1
+ /** Default overall budget for running every registered cleanup hook. */
2
+ const DEFAULT_CLEANUP_TIMEOUT_MS = 5_000;
3
+ /**
4
+ * Coordinates graceful shutdown of the router. Downstream resources
5
+ * (server connections, the MCP server) register async cleanup hooks; a
6
+ * single trigger — a process signal or the disappearance of stdin — runs
7
+ * every hook once (each racing a shared timeout) and resolves the awaited
8
+ * {@link ShutdownCoordinator.whenShutdown} promise so the entry point can
9
+ * force-exit.
10
+ *
11
+ * This is the fix for ghost router processes that linger forever: without
12
+ * it, the spawned downstream servers (and their own child processes, e.g.
13
+ * browser processes forked by a downstream server) keep the Node event
14
+ * loop alive even after the host closes the router's stdin pipe, so the
15
+ * router never exits on its own.
16
+ */
17
+ export class ShutdownCoordinator {
18
+ logger;
19
+ cleanupTimeoutMs;
20
+ cleanups = [];
21
+ resolveShutdown;
22
+ shutdownPromise = new Promise((resolve) => {
23
+ this.resolveShutdown = resolve;
24
+ });
25
+ triggered = false;
26
+ /**
27
+ * @param logger - Structured logger for shutdown diagnostics.
28
+ * @param cleanupTimeoutMs - Overall budget for running every hook
29
+ * before the coordinator stops waiting and lets the process exit.
30
+ * Defaults to 5 seconds; overridable (mainly for tests).
31
+ */
32
+ constructor(logger, cleanupTimeoutMs = DEFAULT_CLEANUP_TIMEOUT_MS) {
33
+ this.logger = logger;
34
+ this.cleanupTimeoutMs = cleanupTimeoutMs;
35
+ }
36
+ /**
37
+ * Whether shutdown has already been triggered. A `true` value means
38
+ * cleanup hooks are running (or have run); newly registered hooks are
39
+ * ignored from this point on.
40
+ */
41
+ get isShuttingDown() {
42
+ return this.triggered;
43
+ }
44
+ /**
45
+ * Registers a cleanup hook. Hooks added after shutdown has already
46
+ * started are ignored — the process is already tearing down.
47
+ *
48
+ * @param cleanup - Async function run once during shutdown.
49
+ */
50
+ register(cleanup) {
51
+ if (this.triggered) {
52
+ return;
53
+ }
54
+ this.cleanups.push(cleanup);
55
+ }
56
+ /**
57
+ * Triggers shutdown: runs every registered hook (each racing the shared
58
+ * cleanup timeout), never rejecting. Safe to call repeatedly — only the
59
+ * first call runs the hooks; later calls return the same promise.
60
+ *
61
+ * @param reason - Why shutdown was triggered (e.g. `signal:SIGTERM`,
62
+ * `stdin-closed`).
63
+ * @returns Resolves when all hooks finish or time out.
64
+ */
65
+ shutdown(reason) {
66
+ if (this.triggered) {
67
+ return this.shutdownPromise;
68
+ }
69
+ this.triggered = true;
70
+ this.logger.info('Shutdown triggered', { reason });
71
+ void this.runCleanups(reason).finally(() => this.resolveShutdown());
72
+ return this.shutdownPromise;
73
+ }
74
+ /**
75
+ * Resolves once shutdown has completed. The router's main loop awaits
76
+ * this so the process stays alive while serving and exits the moment
77
+ * cleanup finishes. Does NOT trigger shutdown on its own.
78
+ */
79
+ whenShutdown() {
80
+ return this.shutdownPromise;
81
+ }
82
+ /**
83
+ * Runs every hook in parallel. A slow or throwing hook never blocks the
84
+ * others or the final exit: each is raced against the shared deadline,
85
+ * and rejections are logged and swallowed.
86
+ */
87
+ async runCleanups(reason) {
88
+ const deadline = this.createDeadline();
89
+ await Promise.all(this.cleanups.map((cleanup, index) => this.runOne(cleanup, index, reason, deadline)));
90
+ this.logger.info('Shutdown complete', { reason });
91
+ }
92
+ /**
93
+ * Runs a single hook against the shared deadline. Defers invocation so
94
+ * a synchronous throw becomes a rejection we can race and swallow
95
+ * rather than an uncaught exception.
96
+ */
97
+ async runOne(cleanup, index, reason, deadline) {
98
+ const pending = Promise.resolve().then(() => cleanup());
99
+ // Swallow late rejections so a hook that fails after the deadline
100
+ // already resolved never surfaces an unhandled-rejection warning.
101
+ void pending.catch(() => { });
102
+ try {
103
+ await Promise.race([pending, deadline]);
104
+ }
105
+ catch (err) {
106
+ this.logger.warn('Cleanup hook failed during shutdown', {
107
+ reason,
108
+ hook: index,
109
+ error: err instanceof Error ? err.message : String(err),
110
+ });
111
+ }
112
+ }
113
+ /** Creates an unref'd timer that resolves after the cleanup budget. */
114
+ createDeadline() {
115
+ return new Promise((resolve) => {
116
+ const handle = setTimeout(resolve, this.cleanupTimeoutMs);
117
+ handle.unref?.();
118
+ });
119
+ }
120
+ }
@@ -0,0 +1,57 @@
1
+ import process from 'node:process';
2
+ /**
3
+ * Signals whose arrival should trigger a graceful shutdown. `SIGHUP` is
4
+ * included because some hosts send it when the controlling terminal
5
+ * disappears.
6
+ */
7
+ const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
8
+ /**
9
+ * Installs process-global triggers that call
10
+ * {@link ShutdownCoordinator.shutdown} when the router should stop:
11
+ *
12
+ * - `SIGINT` / `SIGTERM` / `SIGHUP` — the host asked us to terminate.
13
+ * - stdin `'end'` / `'close'` — the host closed our input pipe without
14
+ * sending a signal. This is the most common cause of ghost router
15
+ * processes that never exit: the MCP SDK's stdio server transport only
16
+ * listens for `'data'` / `'error'` on stdin, so without this hook the
17
+ * router lingers forever while spawned downstream servers keep the
18
+ * event loop alive.
19
+ *
20
+ * A second shutdown signal force-exits immediately so that a stuck
21
+ * cleanup never traps the user with an unresponsive process.
22
+ *
23
+ * @param coordinator - The coordinator to trigger on signal or stdin EOF.
24
+ * @param logger - Structured logger for trigger diagnostics.
25
+ */
26
+ export function installShutdownTriggers(coordinator, logger) {
27
+ for (const signal of SHUTDOWN_SIGNALS) {
28
+ process.on(signal, () => {
29
+ if (coordinator.isShuttingDown) {
30
+ logger.warn('Forcing immediate exit on signal during shutdown', { signal });
31
+ process.exit(1);
32
+ }
33
+ void coordinator.shutdown(`signal:${signal}`);
34
+ });
35
+ }
36
+ watchStdinClose(coordinator);
37
+ }
38
+ /**
39
+ * Treats stdin EOF as a shutdown trigger. The host (IDE/editor/agent)
40
+ * typically ends the router by closing its stdin pipe without sending a
41
+ * signal; without this watcher the router would linger as a ghost
42
+ * process because the SDK ignores stdin EOF.
43
+ *
44
+ * Listeners are removed after the first trigger so a later `'close'`
45
+ * event (e.g. from the SDK tearing the transport down during cleanup)
46
+ * cannot re-enter the coordinator.
47
+ */
48
+ function watchStdinClose(coordinator) {
49
+ const stdin = process.stdin;
50
+ const trigger = () => {
51
+ stdin.off('end', trigger);
52
+ stdin.off('close', trigger);
53
+ void coordinator.shutdown('stdin-closed');
54
+ };
55
+ stdin.on('end', trigger);
56
+ stdin.on('close', trigger);
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-compress-router",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Compress all connected MCP servers into a single router MCP to save tokens",
5
5
  "license": "MIT",
6
6
  "type": "module",