mcp-compress-router 1.6.0 → 1.6.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.
@@ -130,52 +130,101 @@ export async function readCredentials(configPath) {
130
130
  */
131
131
  export async function writeCredentials(configPath, name, credentials, logger) {
132
132
  const credPath = getCredentialsPath(configPath);
133
- // Read existing store (or start fresh)
134
- let store = {};
133
+ const store = (await readCredentialsStore(credPath, 'writing')) ?? {};
134
+ store[name] = credentials;
135
+ const { isNewFile, mode } = await resolveCredentialsMode(credPath);
136
+ // Write atomically (unique temporary sibling + rename): a crash or
137
+ // forced exit can never leave credentials.json truncated, and
138
+ // readCredentials treats invalid JSON as a hard startup error.
139
+ await writeCredentialsFileAtomic(credPath, JSON.stringify(store, null, 2) + '\n', mode);
140
+ // On first creation, set restrictive permissions (owner read/write only)
141
+ if (isNewFile) {
142
+ await restrictNewCredentialsFile(credPath, logger);
143
+ }
144
+ }
145
+ /**
146
+ * Reads the credentials store for a read-modify-write update. A missing
147
+ * file yields `undefined`; any other failure (unreadable file or invalid
148
+ * JSON) is fatal so a damaged store is never silently overwritten.
149
+ *
150
+ * @param credPath - Absolute path to credentials.json.
151
+ * @param action - Action named in the error message.
152
+ * @returns The stored credentials, or undefined when no file exists.
153
+ */
154
+ async function readCredentialsStore(credPath, action) {
135
155
  try {
136
156
  const raw = await fs.readFile(credPath, 'utf-8');
137
157
  const parsed = JSON.parse(raw);
138
158
  if (typeof parsed === 'object' && parsed !== null) {
139
- store = parsed;
159
+ return parsed;
140
160
  }
161
+ return {};
141
162
  }
142
163
  catch (err) {
143
164
  if (isNodeError(err) && err.code === 'ENOENT') {
144
- // File does not exist — will be created below
145
- }
146
- else {
147
- throw new Error(`Failed to read credentials file for writing: ${credPath}`, { cause: err });
165
+ return undefined;
148
166
  }
167
+ throw new Error(`Failed to read credentials file for ${action}: ${credPath}`, { cause: err });
149
168
  }
150
- // Merge in the new/updated entry
151
- store[name] = credentials;
152
- // Determine if this is a first-time creation
153
- let isNewFile = false;
169
+ }
170
+ /**
171
+ * Resolves the permissions to use when replacing the credentials file:
172
+ * an existing file keeps its mode, a new file is created owner-only.
173
+ *
174
+ * @param credPath - Absolute path to credentials.json.
175
+ * @returns Whether the file is new, and the mode to apply.
176
+ */
177
+ async function resolveCredentialsMode(credPath) {
154
178
  try {
155
- await fs.access(credPath);
179
+ const stat = await fs.stat(credPath);
180
+ return { isNewFile: false, mode: stat.mode & 0o777 };
156
181
  }
157
182
  catch {
158
- isNewFile = true;
183
+ return { isNewFile: true, mode: 0o600 };
159
184
  }
160
- // Write the store
161
- await fs.writeFile(credPath, JSON.stringify(store, null, 2) + '\n');
162
- // On first creation, set restrictive permissions (owner read/write only)
163
- if (isNewFile) {
164
- try {
165
- await fs.chmod(credPath, 0o600);
166
- }
167
- catch {
168
- // chmod is a no-op on Windows; if it somehow fails on Unix, log a warning
169
- if (logger) {
170
- logger.info(`Warning: Failed to set restrictive permissions on new credentials file: ${credPath}`);
171
- }
172
- }
173
- // On Windows, verify chmod did something; if not, log a warning
174
- if (process.platform === 'win32' && logger) {
175
- logger.info(`Warning: File permissions cannot be restricted on Windows. ` +
176
- `Credentials stored in: ${credPath}`);
185
+ }
186
+ /**
187
+ * Writes `data` atomically: the content goes to a unique temporary
188
+ * sibling file first, then is renamed over the target, so a crash or a
189
+ * forced exit can never leave a truncated credentials file behind.
190
+ *
191
+ * @param credPath - Target credentials file path.
192
+ * @param data - Full file content to write.
193
+ * @param mode - Permissions to create the replacement file with.
194
+ */
195
+ async function writeCredentialsFileAtomic(credPath, data, mode) {
196
+ const tempPath = `${credPath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
197
+ try {
198
+ await fs.writeFile(tempPath, data, { mode });
199
+ await fs.rename(tempPath, credPath);
200
+ }
201
+ catch (err) {
202
+ await fs.unlink(tempPath).catch(() => { });
203
+ throw err;
204
+ }
205
+ }
206
+ /**
207
+ * Restricts a freshly created credentials file to owner read/write and
208
+ * warns when the platform cannot enforce it.
209
+ *
210
+ * @param credPath - Absolute path to credentials.json.
211
+ * @param logger - Optional logger for permission warnings.
212
+ */
213
+ async function restrictNewCredentialsFile(credPath, logger) {
214
+ try {
215
+ await fs.chmod(credPath, 0o600);
216
+ }
217
+ catch {
218
+ // chmod is a no-op on Windows; if it somehow fails on Unix, log a warning
219
+ if (logger) {
220
+ logger.info(`Warning: Failed to set restrictive permissions on new credentials file: ${credPath}`);
177
221
  }
178
222
  }
223
+ // On Windows, verify chmod did something; if not, log a warning
224
+ if (process.platform === 'win32' && logger) {
225
+ logger.info(`Warning: File permissions cannot be restricted on Windows. ` +
226
+ `Credentials stored in: ${credPath}`);
227
+ }
179
228
  }
180
229
  /**
181
230
  * Removes credentials for a server from the config file.
@@ -187,20 +236,10 @@ export async function writeCredentials(configPath, name, credentials, logger) {
187
236
  */
188
237
  export async function removeCredentials(configPath, name) {
189
238
  const credPath = getCredentialsPath(configPath);
190
- let store = {};
191
- try {
192
- const raw = await fs.readFile(credPath, 'utf-8');
193
- const parsed = JSON.parse(raw);
194
- if (typeof parsed === 'object' && parsed !== null) {
195
- store = parsed;
196
- }
197
- }
198
- catch (err) {
199
- if (isNodeError(err) && err.code === 'ENOENT') {
200
- // File does not exist — nothing to remove
201
- return;
202
- }
203
- throw new Error(`Failed to read credentials file for removal: ${credPath}`, { cause: err });
239
+ const store = await readCredentialsStore(credPath, 'removal');
240
+ if (store === undefined) {
241
+ // File does not exist — nothing to remove
242
+ return;
204
243
  }
205
244
  delete store[name];
206
245
  if (Object.keys(store).length === 0) {
@@ -208,6 +247,7 @@ export async function removeCredentials(configPath, name) {
208
247
  await fs.unlink(credPath);
209
248
  }
210
249
  else {
211
- await fs.writeFile(credPath, JSON.stringify(store, null, 2) + '\n');
250
+ const { mode } = await resolveCredentialsMode(credPath);
251
+ await writeCredentialsFileAtomic(credPath, JSON.stringify(store, null, 2) + '\n', mode);
212
252
  }
213
253
  }
@@ -1,21 +1,27 @@
1
1
  import process from 'node:process';
2
- import { resolveConfigPath, persistAuthRequirements, loadConfig, ServerConnection, invokeWithRecovery, buildCatalog, ShutdownCoordinator, installShutdownTriggers, } from '../services/index.js';
2
+ import { resolveConfigPath, persistAuthRequirements, loadConfig, ServerConnection, invokeWithRecovery, buildCatalog, replaceCatalogContents, ShutdownCoordinator, installShutdownTriggers, } from '../services/index.js';
3
3
  import { Logger } from '../utils/index.js';
4
- import { createGetToolSchemaHandler, buildGetToolSchemaDescription, GetToolSchemaInputSchema, createInvokeToolHandler, InvokeToolInputSchema, } from '../tools/index.js';
5
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { startRouterServer } from './router-server.js';
7
5
  /**
8
6
  * Connects to all enabled downstream servers via ServerConnection.
9
7
  * Disabled servers are skipped entirely. On failure, warm-cache
10
8
  * servers are degraded; cold-cache servers cause fail-fast.
11
9
  *
10
+ * Each connection is registered in the shared `connections` map BEFORE
11
+ * its connect starts, so a shutdown triggered mid-connect still tears
12
+ * the in-flight child process down instead of orphaning it.
13
+ *
12
14
  * @param servers - Validated downstream server configs.
13
15
  * @param configPath - Absolute path to the config file.
14
16
  * @param logger - Structured logger.
15
- * @returns Map of server name to ServerConnection and discovered data.
17
+ * @param connections - Shared connection registry (populated as connects
18
+ * start; used for shutdown cleanup and runtime invocation).
19
+ * @param coordinator - Shutdown coordinator (stops launching new
20
+ * connects once shutdown has started).
21
+ * @returns Discovered data for every connected server, in config order.
16
22
  * @throws When a server cannot connect AND has no tool cache.
17
23
  */
18
- async function connectAllServers(servers, configPath, logger) {
24
+ async function connectAllServers(servers, configPath, logger, connections, coordinator) {
19
25
  const enabledServers = servers.filter((server) => {
20
26
  if (server.enabled === false) {
21
27
  logger.info(`Skipping disabled server "${server.name}"`, { server: server.name });
@@ -24,60 +30,24 @@ async function connectAllServers(servers, configPath, logger) {
24
30
  return true;
25
31
  });
26
32
  const results = await Promise.all(enabledServers.map(async (server) => {
33
+ if (coordinator.isShuttingDown) {
34
+ return undefined;
35
+ }
27
36
  const conn = new ServerConnection(server, configPath, logger);
37
+ connections.set(conn.serverName, conn);
38
+ if (coordinator.isShuttingDown) {
39
+ return undefined;
40
+ }
28
41
  const ds = await conn.connect();
29
42
  return { conn, ds };
30
43
  }));
31
- const connections = new Map();
32
44
  const discovered = [];
33
- for (const { conn, ds } of results) {
34
- connections.set(conn.serverName, conn);
35
- discovered.push(ds);
36
- }
37
- return { connections, discovered };
38
- }
39
- /**
40
- * Creates the MCP server, registers router tools, and starts the
41
- * stdio transport.
42
- *
43
- * @param catalog - The mutable tool catalog.
44
- * @param connections - Live ServerConnection instances keyed by name.
45
- * @param selectionByServer - Per-server tool selection for re-filtering.
46
- * @param logger - Structured logger.
47
- * @returns A cleanup function that closes the MCP server (and its stdio
48
- * transport) during shutdown.
49
- */
50
- async function startRouterServer(catalog, connections, selectionByServer, logger) {
51
- const router = new McpServer({
52
- name: 'mcp-compress-router',
53
- version: '1.0.0',
54
- });
55
- router.registerTool('get_tool_schema', {
56
- title: 'Get Tool Schema',
57
- description: buildGetToolSchemaDescription(catalog),
58
- inputSchema: GetToolSchemaInputSchema,
59
- }, createGetToolSchemaHandler(catalog, logger));
60
- const invokeFn = async (server, tool, args) => {
61
- return invokeWithRecovery(server, tool, args, catalog, connections, selectionByServer, logger);
62
- };
63
- router.registerTool('invoke_tool', {
64
- title: 'Invoke Tool',
65
- description: 'Invoke a specific tool on a connected MCP server. ' +
66
- 'You MUST first use get_tool_schema to retrieve the required parameters ' +
67
- 'for this tool before calling invoke_tool.',
68
- inputSchema: InvokeToolInputSchema,
69
- }, createInvokeToolHandler(catalog, invokeFn, logger));
70
- const transport = new StdioServerTransport();
71
- await router.connect(transport);
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.
45
+ for (const result of results) {
46
+ if (result) {
47
+ discovered.push(result.ds);
79
48
  }
80
- };
49
+ }
50
+ return discovered;
81
51
  }
82
52
  /**
83
53
  * Closes every downstream server connection in parallel. Each
@@ -95,15 +65,10 @@ async function closeAllConnections(connections, logger) {
95
65
  await Promise.all([...connections.values()].map((conn) => conn.close().catch(() => { })));
96
66
  }
97
67
  /**
98
- * Runs the router: loads config, refreshes cached auth requirements,
99
- * connects to downstream servers, builds the catalog, and starts the
100
- * stdio MCP server.
101
- *
102
- * @param configPath - Explicit config path, or undefined for default.
103
- * @param verbose - When true, enables debug-level logging.
68
+ * Loads the config file and derives the per-server tool-selection and
69
+ * compression-level maps used for filtering/rendering.
104
70
  */
105
- export async function runRouter(configPath, verbose) {
106
- const logger = new Logger(verbose ? 'debug' : 'info');
71
+ async function loadConfigForRun(configPath, verbose, logger) {
107
72
  logger.info('Starting mcp-compress-router', {
108
73
  verbose,
109
74
  config: configPath ?? '(default)',
@@ -112,36 +77,6 @@ export async function runRouter(configPath, verbose) {
112
77
  logger.info('Loading configuration', { path: resolved });
113
78
  const servers = await loadConfig(resolved);
114
79
  logger.info('Configuration loaded', { serverCount: servers.length });
115
- // Refresh the cached OAuth auth requirements CONCURRENTLY with the
116
- // downstream connects. Both phases are network-bound, so running them
117
- // sequentially would stack their worst-case latencies; in parallel the
118
- // startup time is bounded by the slower of the two — and each default
119
- // timeout (see timeout.ts) stays well below the 30 s startup budget
120
- // most MCP hosts allow, so even a hung server cannot blow it.
121
- // `Promise.allSettled` avoids unhandled rejections when both phases
122
- // fail; the connect failure is surfaced first because it is the one
123
- // the host needs to act on.
124
- logger.info('Connecting to downstream servers', {
125
- servers: servers.map((s) => s.name),
126
- });
127
- const [authRefresh, connectResult] = await Promise.allSettled([
128
- persistAuthRequirements(resolved, servers, logger),
129
- connectAllServers(servers, resolved, logger),
130
- ]);
131
- if (connectResult.status === 'rejected') {
132
- throw connectResult.reason;
133
- }
134
- if (authRefresh.status === 'rejected') {
135
- throw authRefresh.reason;
136
- }
137
- const { connections, discovered } = connectResult.value;
138
- logger.info('Tools discovered', {
139
- servers: discovered.map((d) => ({
140
- name: d.name,
141
- toolCount: d.tools.length,
142
- status: d.status,
143
- })),
144
- });
145
80
  const selectionByServer = new Map();
146
81
  const compressionLevelByServer = new Map();
147
82
  for (const server of servers) {
@@ -151,12 +86,120 @@ export async function runRouter(configPath, verbose) {
151
86
  });
152
87
  compressionLevelByServer.set(server.name, server.compressionLevel);
153
88
  }
154
- const catalog = buildCatalog(discovered, selectionByServer, logger, compressionLevelByServer);
155
- const closeRouter = await startRouterServer(catalog, connections, selectionByServer, logger);
89
+ return { resolved, servers, selectionByServer, compressionLevelByServer };
90
+ }
91
+ /**
92
+ * Installs the shutdown coordinator + triggers and the shared connection
93
+ * registry. MUST run before any downstream child is spawned so a host
94
+ * disconnect during the connect phase tears in-flight children down.
95
+ */
96
+ function installShutdownManager(logger) {
97
+ const connections = new Map();
156
98
  const coordinator = new ShutdownCoordinator(logger);
157
99
  coordinator.register(() => closeAllConnections(connections, logger));
158
- coordinator.register(closeRouter);
159
100
  installShutdownTriggers(coordinator, logger);
101
+ return { coordinator, connections };
102
+ }
103
+ /**
104
+ * Creates the empty live catalog, its ready signal, and the downstream
105
+ * invocation function. Tool handlers hold the catalog by reference; the
106
+ * final contents are published in place once discovery completes.
107
+ */
108
+ function createCatalogState(connections, selectionByServer, logger) {
109
+ const catalog = {
110
+ servers: [],
111
+ toolMap: new Map(),
112
+ filteredToolNames: new Set(),
113
+ };
114
+ let markCatalogReady;
115
+ const catalogReady = new Promise((resolve) => {
116
+ markCatalogReady = resolve;
117
+ });
118
+ const invokeFn = async (server, tool, args) => {
119
+ return invokeWithRecovery(server, tool, args, catalog, connections, selectionByServer, logger);
120
+ };
121
+ return { catalog, catalogReady, markCatalogReady, invokeFn };
122
+ }
123
+ /**
124
+ * Kicks off the downstream connect phase (including the OAuth
125
+ * auth-requirement refresh) without awaiting it. Both phases run in
126
+ * parallel, each bounded by per-operation timeouts.
127
+ */
128
+ function launchConnectPhase(servers, resolved, connections, coordinator, logger) {
129
+ return (async () => {
130
+ const [authRefresh, connectResult] = await Promise.allSettled([
131
+ persistAuthRequirements(resolved, servers, logger),
132
+ connectAllServers(servers, resolved, logger, connections, coordinator),
133
+ ]);
134
+ if (connectResult.status === 'rejected') {
135
+ throw connectResult.reason;
136
+ }
137
+ if (authRefresh.status === 'rejected') {
138
+ throw authRefresh.reason;
139
+ }
140
+ return connectResult.value;
141
+ })();
142
+ }
143
+ /**
144
+ * Awaits the connect phase, racing it against shutdown. On shutdown the
145
+ * connect phase is abandoned (its in-flight children were already closed
146
+ * by the cleanup hooks). On a cold connect failure, cleanup runs first,
147
+ * then the error is rethrown so the entry point logs + exits non-zero.
148
+ */
149
+ async function awaitConnectOutcome(connectPhase, coordinator) {
150
+ try {
151
+ return await Promise.race([
152
+ connectPhase.then((discovered) => ({ discovered })),
153
+ coordinator.whenShutdownStarted().then(() => ({ shutdown: true })),
154
+ ]);
155
+ }
156
+ catch (err) {
157
+ await coordinator.shutdown('startup-failure');
158
+ throw err;
159
+ }
160
+ }
161
+ /**
162
+ * Publishes the discovered servers into the live catalog, unblocks
163
+ * tools/list, and logs the discovery summary.
164
+ */
165
+ function publishCatalog(catalog, markCatalogReady, discovered, selectionByServer, compressionLevelByServer, logger) {
166
+ replaceCatalogContents(catalog, buildCatalog(discovered, selectionByServer, logger, compressionLevelByServer));
167
+ markCatalogReady();
168
+ logger.info('Tools discovered', {
169
+ servers: discovered.map((d) => ({
170
+ name: d.name,
171
+ toolCount: d.tools.length,
172
+ status: d.status,
173
+ })),
174
+ });
175
+ }
176
+ /**
177
+ * Runs the router: starts the host-facing stdio server IMMEDIATELY (so
178
+ * the host's `initialize` is answered without waiting for downstream
179
+ * connects), then connects to downstream servers concurrently, publishes
180
+ * the discovered tools into the live catalog, and blocks until a
181
+ * shutdown trigger fires.
182
+ *
183
+ * Shutdown triggers are installed before ANY network activity so a host
184
+ * disconnect during the connect phase still tears down in-flight
185
+ * downstream children (the most common cause of orphaned processes).
186
+ *
187
+ * @param configPath - Explicit config path, or undefined for default.
188
+ * @param verbose - When true, enables debug-level logging.
189
+ */
190
+ export async function runRouter(configPath, verbose) {
191
+ const logger = new Logger(verbose ? 'debug' : 'info');
192
+ const { resolved, servers, selectionByServer, compressionLevelByServer } = await loadConfigForRun(configPath, verbose, logger);
193
+ const { coordinator, connections } = installShutdownManager(logger);
194
+ const { catalog, catalogReady, markCatalogReady, invokeFn } = createCatalogState(connections, selectionByServer, logger);
195
+ const closeRouter = await startRouterServer(catalog, invokeFn, logger, coordinator, catalogReady);
196
+ coordinator.register(closeRouter);
197
+ const outcome = await awaitConnectOutcome(launchConnectPhase(servers, resolved, connections, coordinator, logger), coordinator);
198
+ if ('shutdown' in outcome) {
199
+ await coordinator.whenShutdown();
200
+ process.exit(0);
201
+ }
202
+ publishCatalog(catalog, markCatalogReady, outcome.discovered, selectionByServer, compressionLevelByServer, logger);
160
203
  // Block here until a shutdown trigger fires (signal or stdin EOF), then
161
204
  // force-exit so lingering grandchild pipes (e.g. browser processes
162
205
  // forked by a downstream server) cannot keep the router alive. Without
@@ -0,0 +1,117 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
4
+ import { z } from 'zod';
5
+ import { createGetToolSchemaHandler, buildGetToolSchemaDescription } from '../tools/index.js';
6
+ import { createInvokeToolHandler, GetToolSchemaInputSchema, InvokeToolInputSchema, } from '../tools/index.js';
7
+ /** Zod object for get_tool_schema arguments (validation + JSON schema). */
8
+ const getToolSchemaArgs = z.object(GetToolSchemaInputSchema);
9
+ /** Zod object for invoke_tool arguments (validation + JSON schema). */
10
+ const invokeToolArgs = z.object(InvokeToolInputSchema);
11
+ /** Whether a tools/list request is unblocked (catalog ready or shutdown). */
12
+ async function waitForCatalog(catalogReady, coordinator) {
13
+ await Promise.race([
14
+ catalogReady,
15
+ coordinator.whenShutdownStarted().then(() => {
16
+ throw new McpError(ErrorCode.ConnectionClosed, 'Router is shutting down');
17
+ }),
18
+ ]);
19
+ }
20
+ /**
21
+ * Builds the router's two tool entries from the live catalog. Called per
22
+ * `tools/list` request so the compact catalog (and the per-server status)
23
+ * always reflects the latest discovery/reconnect state.
24
+ *
25
+ * @param catalog - The tool catalog (read live).
26
+ * @returns Tool entries for the MCP tools/list response.
27
+ */
28
+ function buildRouterToolList(catalog) {
29
+ return [
30
+ {
31
+ name: 'get_tool_schema',
32
+ title: 'Get Tool Schema',
33
+ description: buildGetToolSchemaDescription(catalog),
34
+ inputSchema: z.toJSONSchema(getToolSchemaArgs),
35
+ },
36
+ {
37
+ name: 'invoke_tool',
38
+ title: 'Invoke Tool',
39
+ description: 'Invoke a specific tool on a connected MCP server. ' +
40
+ 'You MUST first use get_tool_schema to retrieve the required parameters ' +
41
+ 'for this tool before calling invoke_tool.',
42
+ inputSchema: z.toJSONSchema(invokeToolArgs),
43
+ },
44
+ ];
45
+ }
46
+ /**
47
+ * Validates tool arguments against the tool's Zod schema, mirroring the
48
+ * SDK's McpServer behavior of rejecting malformed input with
49
+ * `InvalidParams` (rather than passing garbage into the handlers).
50
+ *
51
+ * @param schema - The Zod object for the tool's arguments.
52
+ * @param toolName - The tool name (for the error message).
53
+ * @param args - The raw arguments from the tools/call request.
54
+ * @returns The validated + trimmed arguments.
55
+ * @throws McpError(InvalidParams) when the arguments do not match.
56
+ */
57
+ function parseToolArguments(schema, toolName, args) {
58
+ const parsed = schema.safeParse(args);
59
+ if (!parsed.success) {
60
+ const detail = parsed.error.issues.map((issue) => issue.message).join('; ');
61
+ throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for tool "${toolName}": ${detail}`);
62
+ }
63
+ return parsed.data;
64
+ }
65
+ /**
66
+ * Creates the router's MCP server (low-level SDK Server) and starts the
67
+ * stdio transport IMMEDIATELY, before downstream servers have connected.
68
+ * The host's `initialize` is answered right away; `tools/list` waits for
69
+ * the catalog to be filled (bounded by the per-server connect timeouts,
70
+ * aborted on shutdown) so the compact catalog always reflects the final
71
+ * discovery state.
72
+ *
73
+ * Tool handlers receive only the catalog (plus the injected invocation
74
+ * function) — never raw transport clients.
75
+ *
76
+ * @param catalog - The mutable tool catalog (empty at start).
77
+ * @param invokeFn - Function forwarding tool calls to downstreams.
78
+ * @param logger - Structured logger.
79
+ * @param coordinator - Shutdown coordinator (aborts pending requests).
80
+ * @param catalogReady - Resolves once the catalog has been populated.
81
+ * @returns A cleanup function closing the router's MCP server.
82
+ */
83
+ export async function startRouterServer(catalog, invokeFn, logger, coordinator, catalogReady) {
84
+ const server = new Server({ name: 'mcp-compress-router', version: '1.0.0' }, { capabilities: { tools: {} } });
85
+ const getToolSchemaHandler = createGetToolSchemaHandler(catalog, logger);
86
+ const invokeToolHandler = createInvokeToolHandler(catalog, invokeFn, logger);
87
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
88
+ await waitForCatalog(catalogReady, coordinator);
89
+ return { tools: buildRouterToolList(catalog) };
90
+ });
91
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
92
+ // Like tools/list, a direct tool call must wait for discovery to
93
+ // publish the catalog (bounded by the per-server connect timeouts,
94
+ // aborted on shutdown) so handlers never see an empty catalog.
95
+ await waitForCatalog(catalogReady, coordinator);
96
+ const { name, arguments: rawArgs } = request.params;
97
+ switch (name) {
98
+ case 'get_tool_schema':
99
+ return getToolSchemaHandler(parseToolArguments(getToolSchemaArgs, name, rawArgs ?? {}));
100
+ case 'invoke_tool':
101
+ return invokeToolHandler(parseToolArguments(invokeToolArgs, name, rawArgs ?? {}));
102
+ default:
103
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
104
+ }
105
+ });
106
+ const transport = new StdioServerTransport();
107
+ await server.connect(transport);
108
+ logger.info('Server started on stdio');
109
+ return async () => {
110
+ try {
111
+ await server.close();
112
+ }
113
+ catch {
114
+ // Ignore close errors during shutdown — the process is exiting.
115
+ }
116
+ };
117
+ }
@@ -89,6 +89,30 @@ export function updateServerInCatalog(catalog, serverName, tools, status, toolSe
89
89
  server.tools = exposed;
90
90
  server.status = status;
91
91
  }
92
+ /**
93
+ * Replaces the contents of a live catalog with a freshly-built one
94
+ * (mutates the same object in place).
95
+ *
96
+ * Used by the entry point to publish discovered servers into the catalog
97
+ * that tool handlers hold by reference: the router starts serving the
98
+ * host immediately with an empty catalog, then swaps the final contents
99
+ * in once discovery completes — without re-registering tool handlers.
100
+ *
101
+ * @param catalog - The catalog to fill (kept by reference by handlers).
102
+ * @param built - The catalog whose contents replace `catalog`'s.
103
+ */
104
+ export function replaceCatalogContents(catalog, built) {
105
+ catalog.servers.length = 0;
106
+ catalog.servers.push(...built.servers);
107
+ catalog.toolMap.clear();
108
+ for (const [key, value] of built.toolMap) {
109
+ catalog.toolMap.set(key, value);
110
+ }
111
+ catalog.filteredToolNames.clear();
112
+ for (const key of built.filteredToolNames) {
113
+ catalog.filteredToolNames.add(key);
114
+ }
115
+ }
92
116
  /**
93
117
  * Looks up tool schemas in the catalog by server and tool names.
94
118
  *
@@ -47,3 +47,131 @@ export function createDedicatedFetch() {
47
47
  dispatcher: agent,
48
48
  });
49
49
  }
50
+ /**
51
+ * Wraps a {@link FetchLike} so the awaits in the HTTP transport connect
52
+ * path are bounded: the SSE session GET, the OAuth metadata discovery
53
+ * GETs, and the OAuth token exchange POST (a TCP-accepting-but-silent
54
+ * server, or a hung token endpoint, would otherwise stall
55
+ * `client.connect()` forever).
56
+ *
57
+ * The timeout covers the response-header phase for every bounded request
58
+ * and additionally the response-body read for requests whose body is
59
+ * consumed during connect (metadata + token exchange). The long-lived SSE
60
+ * response body is deliberately exempt: its `Accept: text/event-stream`
61
+ * header marks it, so it streams for its natural duration. An abort
62
+ * raises a descriptive timeout error instead of undici's bare
63
+ * `AbortError`.
64
+ *
65
+ * Requests that can legitimately run long are NOT bounded:
66
+ * - JSON message POSTs (`tools/call` and friends) — the response headers
67
+ * only arrive once the downstream finishes processing the request.
68
+ * - Any other method (e.g. the session DELETE on close).
69
+ *
70
+ * @param baseFetch - The underlying fetch (e.g. a dedicated undici fetch).
71
+ * @param timeoutMs - Connect timeout in milliseconds.
72
+ * @returns A fetch-compatible function with the connect timeout applied.
73
+ */
74
+ export function createConnectTimeoutFetch(baseFetch, timeoutMs) {
75
+ return (url, init) => {
76
+ const bound = classifyConnectBound(init);
77
+ if (bound === undefined) {
78
+ return baseFetch(url, init);
79
+ }
80
+ const controller = new AbortController();
81
+ const timedOut = { current: false };
82
+ const timer = setTimeout(() => {
83
+ timedOut.current = true;
84
+ controller.abort();
85
+ }, timeoutMs);
86
+ timer.unref?.();
87
+ const signal = init !== undefined && init.signal
88
+ ? AbortSignal.any([init.signal, controller.signal])
89
+ : controller.signal;
90
+ return baseFetch(url, { ...init, signal })
91
+ .then((response) => {
92
+ if (bound === 'headers') {
93
+ clearTimeout(timer);
94
+ return response;
95
+ }
96
+ return boundResponseBody(response, timer, timedOut, timeoutMs);
97
+ })
98
+ .catch((err) => {
99
+ clearTimeout(timer);
100
+ if (timedOut.current) {
101
+ throw timeoutError('response headers', timeoutMs);
102
+ }
103
+ throw err;
104
+ });
105
+ };
106
+ }
107
+ /**
108
+ * Decides whether a request is part of the connect/auth handshake that
109
+ * must be bounded, and how far the timeout reaches. GETs bound their body
110
+ * too (metadata discovery) unless they request an SSE stream — the SSE
111
+ * session GET streams forever and must only be bounded at the header
112
+ * phase. Form-encoded POSTs bound their body (OAuth token exchange).
113
+ * JSON message POSTs are deliberately excluded — their headers arrive
114
+ * only when the downstream finishes the request, which may take minutes
115
+ * for a long tool call.
116
+ *
117
+ * @param init - Request init, if any.
118
+ * @returns The bound scope, or undefined when the request is unbounded.
119
+ */
120
+ function classifyConnectBound(init) {
121
+ const method = (init?.method ?? 'GET').toUpperCase();
122
+ if (method === 'GET') {
123
+ return acceptsEventStream(init) ? 'headers' : 'body';
124
+ }
125
+ if (method === 'POST') {
126
+ const contentType = new Headers(init?.headers).get('content-type') ?? '';
127
+ return contentType.includes('application/x-www-form-urlencoded') ? 'body' : undefined;
128
+ }
129
+ return undefined;
130
+ }
131
+ /**
132
+ * Whether the request asks for an SSE stream (`Accept: text/event-stream`),
133
+ * which is exempt from the body-phase timeout.
134
+ */
135
+ function acceptsEventStream(init) {
136
+ const accept = new Headers(init?.headers).get('accept') ?? '';
137
+ return accept.includes('text/event-stream');
138
+ }
139
+ /**
140
+ * Keeps the connect timer armed after headers arrive and wraps the
141
+ * response body readers so a timeout mid-read surfaces as a descriptive
142
+ * error (and the timer is cleared once a read settles).
143
+ */
144
+ function boundResponseBody(response, timer, timedOut, timeoutMs) {
145
+ response.json = withBodyTimeout(response.json.bind(response), timer, timedOut, timeoutMs);
146
+ response.text = withBodyTimeout(response.text.bind(response), timer, timedOut, timeoutMs);
147
+ response.arrayBuffer = withBodyTimeout(response.arrayBuffer.bind(response), timer, timedOut, timeoutMs);
148
+ response.blob = withBodyTimeout(response.blob.bind(response), timer, timedOut, timeoutMs);
149
+ return response;
150
+ }
151
+ /**
152
+ * Wraps a body reader so it clears the connect timer when it settles and
153
+ * converts an abort caused by that timer into a descriptive error.
154
+ */
155
+ function withBodyTimeout(read, timer, timedOut, timeoutMs) {
156
+ return async () => {
157
+ try {
158
+ return await read();
159
+ }
160
+ catch (err) {
161
+ if (timedOut.current) {
162
+ throw timeoutError('the response body', timeoutMs);
163
+ }
164
+ throw err;
165
+ }
166
+ finally {
167
+ clearTimeout(timer);
168
+ }
169
+ };
170
+ }
171
+ /**
172
+ * Builds the error raised when the connect timeout fires while waiting for
173
+ * the given handshake phase.
174
+ */
175
+ function timeoutError(phase, timeoutMs) {
176
+ return new Error(`Timed out after ${timeoutMs}ms waiting for ${phase} from the downstream server.`);
177
+ }
@@ -2,9 +2,47 @@ 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
4
  import { getDownstreamTimeoutMs } from '../utils/index.js';
5
- import { createDedicatedFetch } from './dedicated-fetch.js';
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
+ /**
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.
14
+ *
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.
17
+ */
18
+ class TimeoutStdioClientTransport extends StdioClientTransport {
19
+ spawnTimeoutMs;
20
+ constructor(params, spawnTimeoutMs) {
21
+ super(params);
22
+ this.spawnTimeoutMs = spawnTimeoutMs;
23
+ }
24
+ async start() {
25
+ let timer;
26
+ const spawnTimeout = new Promise((_resolve, reject) => {
27
+ timer = setTimeout(() => {
28
+ reject(new Error(`Timed out after ${this.spawnTimeoutMs}ms waiting for the stdio server process to start.`));
29
+ }, this.spawnTimeoutMs);
30
+ timer.unref?.();
31
+ });
32
+ try {
33
+ await Promise.race([super.start(), spawnTimeout]);
34
+ }
35
+ catch (err) {
36
+ await this.close().catch(() => { });
37
+ throw err;
38
+ }
39
+ finally {
40
+ if (timer !== undefined) {
41
+ clearTimeout(timer);
42
+ }
43
+ }
44
+ }
45
+ }
8
46
  /**
9
47
  * Returns true when an error is a JSON-RPC "Method not found" response,
10
48
  * which a downstream server returns for `tools/list` when it advertises
@@ -114,11 +152,11 @@ export function createTransport(server, getAuthProvider) {
114
152
  if (!server.command) {
115
153
  throw new Error(`Server "${server.name}" (stdio) is missing command`);
116
154
  }
117
- return new StdioClientTransport({
155
+ return new TimeoutStdioClientTransport({
118
156
  command: server.command,
119
157
  args: server.args,
120
158
  env: server.env,
121
- });
159
+ }, getDownstreamTimeoutMs());
122
160
  }
123
161
  // http or streamable-http
124
162
  if (!server.url) {
@@ -133,7 +171,10 @@ export function createTransport(server, getAuthProvider) {
133
171
  requestInit: Object.keys(requestInit).length > 0 ? requestInit : undefined,
134
172
  authProvider,
135
173
  // Route all of this server's traffic through its own undici agent so
136
- // it uses a private connection pool instead of the shared default one.
137
- fetch: createDedicatedFetch(),
174
+ // it uses a private connection pool instead of the shared default one,
175
+ // and bound the connect/auth handshakes (SSE session GET, OAuth
176
+ // metadata + token flows) so a silent server cannot stall startup —
177
+ // runtime message POSTs are deliberately left unbounded.
178
+ fetch: createConnectTimeoutFetch(createDedicatedFetch(), getDownstreamTimeoutMs()),
138
179
  });
139
180
  }
@@ -1,4 +1,4 @@
1
- export { buildCatalog, lookupTools } from './catalog.js';
1
+ export { buildCatalog, lookupTools, replaceCatalogContents } 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';
@@ -22,6 +22,10 @@ export class ShutdownCoordinator {
22
22
  shutdownPromise = new Promise((resolve) => {
23
23
  this.resolveShutdown = resolve;
24
24
  });
25
+ resolveShutdownStarted;
26
+ shutdownStartedPromise = new Promise((resolve) => {
27
+ this.resolveShutdownStarted = resolve;
28
+ });
25
29
  triggered = false;
26
30
  /**
27
31
  * @param logger - Structured logger for shutdown diagnostics.
@@ -53,6 +57,15 @@ export class ShutdownCoordinator {
53
57
  }
54
58
  this.cleanups.push(cleanup);
55
59
  }
60
+ /**
61
+ * Resolves as soon as shutdown is triggered, BEFORE cleanup hooks run.
62
+ * Lets long-running phases (downstream connects, pending MCP requests)
63
+ * abort promptly the moment the host disconnects, instead of waiting
64
+ * for the full cleanup window.
65
+ */
66
+ whenShutdownStarted() {
67
+ return this.shutdownStartedPromise;
68
+ }
56
69
  /**
57
70
  * Triggers shutdown: runs every registered hook (each racing the shared
58
71
  * cleanup timeout), never rejecting. Safe to call repeatedly — only the
@@ -67,6 +80,7 @@ export class ShutdownCoordinator {
67
80
  return this.shutdownPromise;
68
81
  }
69
82
  this.triggered = true;
83
+ this.resolveShutdownStarted();
70
84
  this.logger.info('Shutdown triggered', { reason });
71
85
  void this.runCleanups(reason).finally(() => this.resolveShutdown());
72
86
  return this.shutdownPromise;
@@ -47,13 +47,39 @@ function serializeCacheWrite(cachePath, task) {
47
47
  function isNodeError(err) {
48
48
  return err instanceof Error && typeof err.code === 'string';
49
49
  }
50
+ /**
51
+ * Sanitizes a parsed cache store: keeps only entries that are plain
52
+ * objects carrying a `tools` array, and drops everything else. A cache
53
+ * file that is damaged in parts therefore degrades to the subset of
54
+ * still-valid entries instead of being rejected wholesale; a root that
55
+ * is not a plain object degrades to an empty store.
56
+ *
57
+ * @param parsed - The raw JSON.parse() result of the cache file.
58
+ * @returns A validated store containing only well-formed entries.
59
+ */
60
+ function sanitizeStore(parsed) {
61
+ const store = {};
62
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
63
+ return store;
64
+ }
65
+ for (const [serverName, entry] of Object.entries(parsed)) {
66
+ const candidate = entry;
67
+ if (candidate !== null && typeof candidate === 'object' && Array.isArray(candidate.tools)) {
68
+ store[serverName] = candidate;
69
+ }
70
+ }
71
+ return store;
72
+ }
50
73
  /**
51
74
  * Reads the full tool cache store from disk. Returns an empty object
52
- * when the file does not exist.
75
+ * when the file does not exist. A corrupted cache file (invalid JSON,
76
+ * a non-object root, or entries without a `tools` array) never throws:
77
+ * it is treated as empty or partially empty so a damaged cache can
78
+ * never break the router. The next write simply rebuilds the file.
53
79
  *
54
80
  * @param configPath - Absolute path to the mcp.json file.
55
- * @returns The tool cache store, or empty object if file is absent.
56
- * @throws If the file exists but contains invalid JSON.
81
+ * @returns The tool cache store (possibly empty or partial).
82
+ * @throws Only on unexpected I/O errors (e.g. permission denied).
57
83
  */
58
84
  async function readCacheStore(configPath) {
59
85
  const cachePath = getCachePath(configPath);
@@ -67,17 +93,36 @@ async function readCacheStore(configPath) {
67
93
  }
68
94
  throw new Error(`Failed to read tool cache file: ${cachePath}`, { cause: err });
69
95
  }
70
- let parsed;
71
96
  try {
72
- parsed = JSON.parse(raw);
97
+ return sanitizeStore(JSON.parse(raw));
73
98
  }
74
- catch (err) {
75
- throw new Error(`Tool cache file contains invalid JSON: ${cachePath}`, { cause: err });
99
+ catch {
100
+ // Invalid JSON: treat the whole cache as empty rather than throwing.
101
+ // loadToolCache reports "no cache" and saveToolCache rebuilds it.
102
+ return {};
76
103
  }
77
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
78
- throw new Error(`Tool cache file must contain a JSON object: ${cachePath}`);
104
+ }
105
+ /**
106
+ * Writes `data` to `cachePath` atomically: the content is first written
107
+ * to a unique temporary sibling file and then renamed over the target.
108
+ * Renaming within the same directory is atomic on POSIX, so a reader
109
+ * (or a crash mid-write) can never observe a truncated or half-written
110
+ * cache file — it sees either the previous complete file or the new
111
+ * complete file.
112
+ *
113
+ * @param cachePath - Target cache file path.
114
+ * @param data - Full file content to write.
115
+ */
116
+ async function writeCacheFileAtomic(cachePath, data) {
117
+ const tempPath = `${cachePath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
118
+ try {
119
+ await fs.writeFile(tempPath, data);
120
+ await fs.rename(tempPath, cachePath);
121
+ }
122
+ catch (err) {
123
+ await fs.unlink(tempPath).catch(() => { });
124
+ throw err;
79
125
  }
80
- return parsed;
81
126
  }
82
127
  /**
83
128
  * Saves discovered tools to the on-disk tool cache for a single server.
@@ -98,7 +143,7 @@ export async function saveToolCache(configPath, serverName, tools) {
98
143
  tools,
99
144
  cachedAt: new Date().toISOString(),
100
145
  };
101
- await fs.writeFile(cachePath, JSON.stringify(store, null, 2) + '\n');
146
+ await writeCacheFileAtomic(cachePath, JSON.stringify(store, null, 2) + '\n');
102
147
  });
103
148
  }
104
149
  /**
@@ -108,8 +153,8 @@ export async function saveToolCache(configPath, serverName, tools) {
108
153
  *
109
154
  * @param configPath - Absolute path to the mcp.json file.
110
155
  * @param serverName - The server whose cached tools to load.
111
- * @returns The cached tool descriptors, or `undefined` when not cached.
112
- * @throws If the cache file exists but contains invalid JSON.
156
+ * @returns The cached tool descriptors, or `undefined` when not cached
157
+ * (including when the cache file is missing or corrupted).
113
158
  */
114
159
  export async function loadToolCache(configPath, serverName) {
115
160
  const store = await readCacheStore(configPath);
@@ -143,7 +188,7 @@ export async function clearToolCache(configPath, serverName) {
143
188
  await fs.unlink(cachePath).catch(() => { });
144
189
  }
145
190
  else {
146
- await fs.writeFile(cachePath, JSON.stringify(store, null, 2) + '\n');
191
+ await writeCacheFileAtomic(cachePath, JSON.stringify(store, null, 2) + '\n');
147
192
  }
148
193
  });
149
194
  }
@@ -1,12 +1,51 @@
1
1
  import { spawn } from 'node:child_process';
2
+ /**
3
+ * Splits a command line into executable + arguments, honoring shell-style
4
+ * quoting so paths with spaces work. Supports single and double quotes;
5
+ * no escape sequences (environment overrides never need them).
6
+ *
7
+ * @param input - The command line to split.
8
+ * @returns The program and its pre-set arguments.
9
+ */
10
+ function splitCommandLine(input) {
11
+ const parts = [];
12
+ let current = '';
13
+ let quote;
14
+ for (const char of input) {
15
+ if (quote !== undefined) {
16
+ if (char === quote) {
17
+ quote = undefined;
18
+ }
19
+ else {
20
+ current += char;
21
+ }
22
+ }
23
+ else if (char === '"' || char === "'") {
24
+ quote = char;
25
+ }
26
+ else if (char === ' ' || char === '\t') {
27
+ if (current.length > 0) {
28
+ parts.push(current);
29
+ current = '';
30
+ }
31
+ }
32
+ else {
33
+ current += char;
34
+ }
35
+ }
36
+ if (current.length > 0) {
37
+ parts.push(current);
38
+ }
39
+ return parts;
40
+ }
2
41
  /**
3
42
  * Opens a URL in the default browser using the platform-native command.
4
43
  *
5
44
  * The browser command can be overridden with the
6
45
  * `MCP_COMPRESS_ROUTER_BROWSER` environment variable. Set it to the
7
46
  * executable plus any preset arguments (e.g.
8
- * `node /path/to/headless-browser.js --flag`); the URL is always appended
9
- * as a single, final argument. No shell is used, so there is no
47
+ * `node "/path/with spaces/headless-browser.js" --flag`); the URL is always
48
+ * appended as a single, final argument. No shell is used, so there is no
10
49
  * shell-injection risk — this also makes the override safe to drive OAuth
11
50
  * flows in headless and CI environments.
12
51
  *
@@ -15,7 +54,7 @@ import { spawn } from 'node:child_process';
15
54
  export async function openBrowser(url) {
16
55
  const customBrowser = process.env.MCP_COMPRESS_ROUTER_BROWSER;
17
56
  if (customBrowser && customBrowser.trim().length > 0) {
18
- const [command, ...presetArgs] = customBrowser.trim().split(/\s+/);
57
+ const [command, ...presetArgs] = splitCommandLine(customBrowser.trim());
19
58
  return spawnBrowser(command, [...presetArgs, url]);
20
59
  }
21
60
  const platform = process.platform;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-compress-router",
3
- "version": "1.6.0",
3
+ "version": "1.6.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",