mcp-compress-router 1.6.3 → 1.6.4

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,22 +1,35 @@
1
1
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
2
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
3
3
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
- import { getDownstreamTimeoutMs } from '../utils/index.js';
4
+ import { getDownstreamTimeoutMs, killProcessTree } from '../utils/index.js';
5
5
  import { createDedicatedFetch, createConnectTimeoutFetch } from './dedicated-fetch.js';
6
6
  /** JSON-RPC error code for "Method not found". */
7
7
  const METHOD_NOT_FOUND = -32601;
8
8
  /**
9
9
  * A {@link StdioClientTransport} whose `start()` (the child-process spawn
10
- * wait inside `client.connect()`) is bounded by a timeout. The SDK's own
11
- * transport resolves `start()` only on the process `'spawn'` event, so a
12
- * spawn that never completes — e.g. an executable on a stalled filesystem —
13
- * would stall the connect phase forever with no timeout of any kind.
10
+ * wait inside `client.connect()`) is bounded by a timeout, and whose
11
+ * `close()` terminates the spawned process tree.
14
12
  *
15
- * On timeout the child is force-closed (graduated kill) and the attempt
16
- * is rejected, so the router degrades or fails fast instead of hanging.
13
+ * The SDK's own transport resolves `start()` only on the process `'spawn'`
14
+ * event, so a spawn that never completes — e.g. an executable on a stalled
15
+ * filesystem — would stall the connect phase forever with no timeout of
16
+ * any kind. On timeout the child is force-closed and the attempt is
17
+ * rejected, so the router degrades or fails fast instead of hanging.
18
+ *
19
+ * The SDK also only signals the direct child on close. When the configured
20
+ * command is a wrapper (`npx`, `npm exec`), the actual MCP server is a
21
+ * grandchild that the SDK cannot reach — and if the wrapper exits while
22
+ * that grandchild still holds the inherited stdio pipes, the SDK skips its
23
+ * own SIGTERM/SIGKILL escalation and the server is orphaned. This
24
+ * transport therefore kills the whole descendant tree as well, and caches
25
+ * its close promise: `Client.connect()` may start the close
26
+ * fire-and-forget when the handshake fails, so a later `client.close()`
27
+ * must await the same full cleanup instead of returning immediately.
17
28
  */
18
29
  class TimeoutStdioClientTransport extends StdioClientTransport {
19
30
  spawnTimeoutMs;
31
+ childPid;
32
+ closeInFlight;
20
33
  constructor(params, spawnTimeoutMs) {
21
34
  super(params);
22
35
  this.spawnTimeoutMs = spawnTimeoutMs;
@@ -30,7 +43,12 @@ class TimeoutStdioClientTransport extends StdioClientTransport {
30
43
  timer.unref?.();
31
44
  });
32
45
  try {
33
- await Promise.race([super.start(), spawnTimeout]);
46
+ const startPromise = super.start();
47
+ // `StdioClientTransport.start()` assigns the child process
48
+ // synchronously, so capture the pid before a failed handshake can
49
+ // clear the reference and leave the tree unkillable.
50
+ this.childPid = this.pid ?? undefined;
51
+ await Promise.race([startPromise, spawnTimeout]);
34
52
  }
35
53
  catch (err) {
36
54
  await this.close().catch(() => { });
@@ -42,6 +60,19 @@ class TimeoutStdioClientTransport extends StdioClientTransport {
42
60
  }
43
61
  }
44
62
  }
63
+ close() {
64
+ this.closeInFlight ??= this.performClose();
65
+ return this.closeInFlight;
66
+ }
67
+ async performClose() {
68
+ this.childPid ??= this.pid ?? undefined;
69
+ const treeTermination = this.childPid === undefined ? undefined : killProcessTree(this.childPid);
70
+ // The tree kill runs concurrently with the SDK's graduated close: the
71
+ // descendant snapshot is only valid while the direct child is alive,
72
+ // and killing the grandchildren lets the SDK's own close resolve fast.
73
+ await super.close();
74
+ await treeTermination;
75
+ }
45
76
  }
46
77
  /**
47
78
  * Returns true when an error is a JSON-RPC "Method not found" response,
@@ -197,6 +197,11 @@ export class ServerConnection {
197
197
  };
198
198
  }
199
199
  catch (err) {
200
+ // Tear down any half-initialized client. The connect may have
201
+ // succeeded while `tools/list` failed, in which case a live
202
+ // transport (and its spawned child process) would otherwise linger
203
+ // until the next reconnect attempt or shutdown.
204
+ await this.closeClient();
200
205
  // Record the failure on the connection so the cooldown engages
201
206
  // (status != 'ok') and subsequent callers see the real reason
202
207
  // rather than re-running the full connect→fail cycle with no
@@ -6,5 +6,6 @@ export { expandEnvField } from './expand-env.js';
6
6
  export { Logger } from './logger.js';
7
7
  export { parseJsonc } from './parse-jsonc.js';
8
8
  export { atomicWriteFile } from './atomic-write.js';
9
+ export { killProcessTree } from './process-tree.js';
9
10
  export { VALID_COMPRESSION_LEVELS, isCompressionLevel } from './compression-level.js';
10
11
  export { getDownstreamTimeoutMs, getAuthDiscoveryTimeoutMs, createTimeoutFetch, } from './timeout.js';
@@ -0,0 +1,161 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ /** How long to wait for a tree to exit after SIGTERM before SIGKILL. */
5
+ const DEFAULT_GRACE_MS = 1_000;
6
+ /** Poll interval used while waiting for processes to disappear. */
7
+ const POLL_INTERVAL_MS = 25;
8
+ /** Process-table size bound for `ps`; fits even large host tables. */
9
+ const PS_MAX_BUFFER = 16 * 1024 * 1024;
10
+ /**
11
+ * Terminates a process and every descendant it has spawned.
12
+ *
13
+ * This is required for stdio downstream servers launched through a
14
+ * wrapper — `npx`, `npm exec`, a shell script — where the actual MCP
15
+ * server is a grandchild of the router. The MCP SDK's transport only
16
+ * signals the direct child, so a wrapper that exits (or is killed)
17
+ * while the real server still holds the inherited stdio pipes leaves
18
+ * that server orphaned with a dead peer.
19
+ *
20
+ * On POSIX the descendant list is resolved from one `ps` snapshot
21
+ * taken while the tree is still intact, then the whole tree gets
22
+ * `SIGTERM` followed by `SIGKILL` for survivors after a short grace
23
+ * period. On Windows a `taskkill /T /F` is used.
24
+ *
25
+ * Best-effort and never rejects: missing processes, a failed
26
+ * `ps`/`taskkill` invocation, and permission errors are ignored.
27
+ *
28
+ * @param rootPid - Pid of the direct child (the wrapper, when one is
29
+ * used).
30
+ */
31
+ export async function killProcessTree(rootPid) {
32
+ if (process.platform === 'win32') {
33
+ await taskkillTree(rootPid);
34
+ return;
35
+ }
36
+ const pids = await collectTreePids(rootPid);
37
+ signalProcesses(pids, 'SIGTERM');
38
+ await waitForExit(pids, DEFAULT_GRACE_MS);
39
+ signalProcesses(pids.filter(isAlive), 'SIGKILL');
40
+ }
41
+ /**
42
+ * Collects `rootPid` plus all of its descendants from a single `ps`
43
+ * snapshot. Falls back to just the root when the process table cannot
44
+ * be read.
45
+ *
46
+ * @param rootPid - Pid of the tree root.
47
+ * @returns Root pid followed by its descendants.
48
+ */
49
+ async function collectTreePids(rootPid) {
50
+ const table = await readProcessTable();
51
+ const childrenByParent = new Map();
52
+ for (const entry of table) {
53
+ const children = childrenByParent.get(entry.ppid);
54
+ if (children === undefined) {
55
+ childrenByParent.set(entry.ppid, [entry.pid]);
56
+ }
57
+ else {
58
+ children.push(entry.pid);
59
+ }
60
+ }
61
+ const pids = [rootPid];
62
+ for (let index = 0; index < pids.length; index += 1) {
63
+ const children = childrenByParent.get(pids[index]);
64
+ if (children !== undefined) {
65
+ pids.push(...children);
66
+ }
67
+ }
68
+ return pids;
69
+ }
70
+ /**
71
+ * Reads the POSIX process table via `ps`. Returns an empty list when
72
+ * `ps` is unavailable or fails.
73
+ *
74
+ * @returns Parsed pid/parent-pid rows.
75
+ */
76
+ async function readProcessTable() {
77
+ try {
78
+ const { stdout } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid='], {
79
+ maxBuffer: PS_MAX_BUFFER,
80
+ });
81
+ const entries = [];
82
+ for (const line of stdout.split('\n')) {
83
+ const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
84
+ if (match !== null) {
85
+ entries.push({ pid: Number(match[1]), ppid: Number(match[2]) });
86
+ }
87
+ }
88
+ return entries;
89
+ }
90
+ catch {
91
+ return [];
92
+ }
93
+ }
94
+ /**
95
+ * Sends `signal` to every pid, ignoring pids that are already gone or
96
+ * cannot be signalled.
97
+ *
98
+ * @param pids - Target process ids.
99
+ * @param signal - Signal to deliver.
100
+ */
101
+ function signalProcesses(pids, signal) {
102
+ for (const pid of pids) {
103
+ try {
104
+ process.kill(pid, signal);
105
+ }
106
+ catch {
107
+ // Already exited or not signalable; nothing to do.
108
+ }
109
+ }
110
+ }
111
+ /**
112
+ * Returns whether a pid still exists.
113
+ *
114
+ * @param pid - Process id to probe.
115
+ */
116
+ function isAlive(pid) {
117
+ try {
118
+ process.kill(pid, 0);
119
+ return true;
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ }
125
+ /**
126
+ * Waits until every pid is gone or the grace period expires.
127
+ *
128
+ * @param pids - Process ids to await.
129
+ * @param timeoutMs - Maximum time to wait.
130
+ */
131
+ async function waitForExit(pids, timeoutMs) {
132
+ const deadline = Date.now() + timeoutMs;
133
+ while (Date.now() < deadline && pids.some(isAlive)) {
134
+ await delay(POLL_INTERVAL_MS);
135
+ }
136
+ }
137
+ /**
138
+ * Force-kills a Windows process tree rooted at `pid` and falls back to
139
+ * a direct kill if `taskkill` is unavailable.
140
+ *
141
+ * @param pid - Root process id.
142
+ */
143
+ async function taskkillTree(pid) {
144
+ try {
145
+ await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F']);
146
+ }
147
+ catch {
148
+ // taskkill is unavailable or the process is already gone.
149
+ }
150
+ signalProcesses([pid], 'SIGKILL');
151
+ }
152
+ /**
153
+ * Promise that resolves after `ms` milliseconds.
154
+ *
155
+ * @param ms - Delay in milliseconds.
156
+ */
157
+ function delay(ms) {
158
+ return new Promise((resolve) => {
159
+ setTimeout(resolve, ms);
160
+ });
161
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-compress-router",
3
- "version": "1.6.3",
3
+ "version": "1.6.4",
4
4
  "description": "Compress all connected MCP servers into a single router MCP to save tokens",
5
5
  "license": "MIT",
6
6
  "type": "module",