mcp-compress-router 1.5.0 → 1.5.2

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
  }
@@ -20,3 +20,76 @@ export class GuidedAuthError extends Error {
20
20
  this.serverName = serverName;
21
21
  }
22
22
  }
23
+ /**
24
+ * HTTP status codes that indicate an authentication or authorization
25
+ * failure at the transport layer. The MCP SDK's streamable HTTP
26
+ * transport surfaces the upstream response status as a numeric `code`
27
+ * property on the thrown error — e.g. 401 when the server rejects a
28
+ * request without driving the SDK's `redirectToAuthorization` flow.
29
+ */
30
+ const AUTH_ERROR_STATUSES = new Set([401, 403]);
31
+ /**
32
+ * Error message substrings that indicate a downstream OAuth /
33
+ * access-token failure rather than a network, transport, or tool-level
34
+ * error. These surface when a server rejects a request in the response
35
+ * body (e.g. with an OAuth error code) instead of returning a clean 401
36
+ * that would drive the SDK's `redirectToAuthorization` flow (which
37
+ * throws {@link GuidedAuthError} directly).
38
+ *
39
+ * Matching is case-insensitive to tolerate casing differences across
40
+ * SDK versions and server implementations.
41
+ */
42
+ const AUTH_ERROR_PATTERNS = [
43
+ 'invalid_token',
44
+ 'invalid_grant',
45
+ 'invalid_client',
46
+ 'missing or invalid access token',
47
+ ];
48
+ /**
49
+ * Reads the HTTP status code attached to an error, if any.
50
+ *
51
+ * The MCP SDK's `StreamableHTTPError` exposes the upstream HTTP status
52
+ * as a numeric `code` property. JSON-RPC error codes (e.g. `-32601`
53
+ * Method not found) are negative and never collide with HTTP status
54
+ * codes, so a positive value here is unambiguously an HTTP status.
55
+ * Duck-typed to avoid importing the SDK error class (mirrors the
56
+ * `isMethodNotFound` approach in `discovery.ts`).
57
+ *
58
+ * @param err - The thrown value from connect, reconnect, or invoke.
59
+ * @returns The HTTP status code, or `undefined` when none is present.
60
+ */
61
+ function getHttpStatus(err) {
62
+ if (typeof err === 'object' && err !== null && 'code' in err) {
63
+ const code = err.code;
64
+ return typeof code === 'number' ? code : undefined;
65
+ }
66
+ return undefined;
67
+ }
68
+ /**
69
+ * Determines whether an error represents an authentication failure
70
+ * (missing, invalid, or expired OAuth credentials) rather than a
71
+ * network, transport, or tool-level error.
72
+ *
73
+ * Returns true for {@link GuidedAuthError} instances, for raw
74
+ * transport errors carrying an HTTP 401/403 status (read from the
75
+ * `code` property), and for errors whose message carries an OAuth
76
+ * error code such as `invalid_token` or `invalid_grant`. Without
77
+ * this, a server that rejects a request is misclassified as a generic
78
+ * connection failure, and the guided error tells the user to check
79
+ * their network instead of running `login`.
80
+ *
81
+ * @param err - The error thrown during connect, reconnect, or invoke.
82
+ * @returns True when the error indicates authentication is required.
83
+ */
84
+ export function isAuthError(err) {
85
+ if (err instanceof GuidedAuthError) {
86
+ return true;
87
+ }
88
+ const status = getHttpStatus(err);
89
+ if (status !== undefined && AUTH_ERROR_STATUSES.has(status)) {
90
+ return true;
91
+ }
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ const lower = message.toLowerCase();
94
+ return AUTH_ERROR_PATTERNS.some((pattern) => lower.includes(pattern));
95
+ }
@@ -95,8 +95,7 @@ function restartGuidance(isStdio) {
95
95
  : 'If the issue persists, restart the MCP server in your coding agent.';
96
96
  return (restartClause +
97
97
  ' If you have already fixed the issue, restart the MCP server in your ' +
98
- 'coding agent (e.g. restart Claude Code, opencode, or Codex) so it ' +
99
- 're-initializes the connection.');
98
+ 'coding agent so it re-initializes the connection.');
100
99
  }
101
100
  function extractMessage(err) {
102
101
  if (err instanceof Error) {
@@ -2,10 +2,12 @@ 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';
8
10
  export { computeAuthStatus, persistAuthRequirements } from './auth-status.js';
9
11
  export { discoverAuth } from './oauth-discovery.js';
10
- export { GuidedAuthError } from './auth-errors.js';
12
+ export { GuidedAuthError, isAuthError } from './auth-errors.js';
11
13
  export { buildGuidedError } from './guided-error.js';
@@ -1,5 +1,5 @@
1
1
  import { updateServerInCatalog } from './catalog.js';
2
- import { buildGuidedError, GuidedAuthError } from './index.js';
2
+ import { buildGuidedError, isAuthError } from './index.js';
3
3
  /**
4
4
  * Set of error message substrings that indicate a recoverable
5
5
  * network/transport failure (as opposed to a tool-level or protocol
@@ -30,7 +30,7 @@ const RECOVERABLE_PATTERNS = [
30
30
  * @returns True when a reconnect + retry might succeed.
31
31
  */
32
32
  export function isRecoverable(err) {
33
- if (err instanceof GuidedAuthError) {
33
+ if (isAuthError(err)) {
34
34
  return true;
35
35
  }
36
36
  const message = err instanceof Error ? err.message : String(err);
@@ -154,7 +154,7 @@ async function invokeToolWithRetry(conn, tool, args, server, serverConfig, selec
154
154
  // server is reachable, so 'unavailable' would be misleading —
155
155
  // surface auth errors as 'authentication required', re-throw
156
156
  // the rest as-is so the caller sees the real downstream error.
157
- if (retryErr instanceof GuidedAuthError) {
157
+ if (isAuthError(retryErr)) {
158
158
  throw buildGuidedError(serverConfig, retryErr, 'unauthorized', true);
159
159
  }
160
160
  throw retryErr;
@@ -162,7 +162,7 @@ async function invokeToolWithRetry(conn, tool, args, server, serverConfig, selec
162
162
  // Reconnect itself failed: the server is down or requires auth.
163
163
  // doReconnect has already transitioned the connection's status
164
164
  // + cooldown so subsequent calls back off.
165
- const status = retryErr instanceof GuidedAuthError ? 'unauthorized' : 'unavailable';
165
+ const status = isAuthError(retryErr) ? 'unauthorized' : 'unavailable';
166
166
  throw buildGuidedError(serverConfig, retryErr, status, true);
167
167
  }
168
168
  }
@@ -2,7 +2,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
2
  import { createTransport, listToolsOrEmpty } from './discovery.js';
3
3
  import { OAuthCredentialManager } from './oauth.js';
4
4
  import { saveToolCache, loadToolCache } from './tool-cache.js';
5
- import { GuidedAuthError } from './index.js';
5
+ import { isAuthError } from './index.js';
6
6
  /**
7
7
  * 30-second cooldown after a failed reconnect attempt. Subsequent
8
8
  * `invokeWithRecovery` calls within this window return the cached
@@ -122,7 +122,7 @@ export class ServerConnection {
122
122
  cause: err,
123
123
  });
124
124
  }
125
- const status = err instanceof GuidedAuthError ? 'unauthorized' : 'unavailable';
125
+ const status = isAuthError(err) ? 'unauthorized' : 'unavailable';
126
126
  this._status = status;
127
127
  this.logger.warn(`Server "${this.server.name}" failed (${status}) — using ${cachedTools.length} cached tools`, { server: this.server.name, type: this.server.type, error: message, status });
128
128
  return {
@@ -202,7 +202,7 @@ export class ServerConnection {
202
202
  // backoff. Auth failures are classified as 'unauthorized' so
203
203
  // guided errors point at `login` instead of "connection failed".
204
204
  this._lastError = err instanceof Error ? err.message : String(err);
205
- this._status = err instanceof GuidedAuthError ? 'unauthorized' : 'unavailable';
205
+ this._status = isAuthError(err) ? 'unauthorized' : 'unavailable';
206
206
  throw err;
207
207
  }
208
208
  }
@@ -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.2",
4
4
  "description": "Compress all connected MCP servers into a single router MCP to save tokens",
5
5
  "license": "MIT",
6
6
  "type": "module",