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
|
|
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
|
}
|
package/build/services/index.js
CHANGED
|
@@ -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
|
+
}
|