mcp-compress-router 1.6.1 → 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.
- package/build/cli/config-io.js +85 -45
- package/build/cli/router-runner.js +138 -95
- package/build/cli/router-server.js +117 -0
- package/build/services/catalog.js +24 -0
- package/build/services/dedicated-fetch.js +128 -0
- package/build/services/discovery.js +46 -5
- package/build/services/index.js +1 -1
- package/build/services/shutdown-coordinator.js +14 -0
- package/build/utils/open-browser.js +42 -3
- package/package.json +1 -1
package/build/cli/config-io.js
CHANGED
|
@@ -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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
159
|
+
return parsed;
|
|
140
160
|
}
|
|
161
|
+
return {};
|
|
141
162
|
}
|
|
142
163
|
catch (err) {
|
|
143
164
|
if (isNodeError(err) && err.code === 'ENOENT') {
|
|
144
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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.
|
|
179
|
+
const stat = await fs.stat(credPath);
|
|
180
|
+
return { isNewFile: false, mode: stat.mode & 0o777 };
|
|
156
181
|
}
|
|
157
182
|
catch {
|
|
158
|
-
isNewFile
|
|
183
|
+
return { isNewFile: true, mode: 0o600 };
|
|
159
184
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
* @
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
*
|
|
99
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/build/services/index.js
CHANGED
|
@@ -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;
|
|
@@ -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/
|
|
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()
|
|
57
|
+
const [command, ...presetArgs] = splitCommandLine(customBrowser.trim());
|
|
19
58
|
return spawnBrowser(command, [...presetArgs, url]);
|
|
20
59
|
}
|
|
21
60
|
const platform = process.platform;
|