mcp-compress-router 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/build/cli/login-command.js +13 -1
- package/build/cli/router-runner.js +76 -21
- package/build/services/auth-errors.js +22 -0
- package/build/services/catalog.js +44 -0
- package/build/services/discovery.js +13 -59
- package/build/services/guided-error.js +106 -0
- package/build/services/index.js +8 -2
- package/build/services/invoke-with-recovery.js +169 -0
- package/build/services/oauth.js +193 -4
- package/build/services/server-connection.js +291 -0
- package/build/services/shutdown-coordinator.js +120 -0
- package/build/services/shutdown-triggers.js +57 -0
- package/build/services/tool-cache.js +149 -0
- package/build/utils/expand-env.js +2 -1
- package/build/utils/index.js +0 -3
- package/build/utils/logger.js +15 -3
- package/build/utils/text-format.js +20 -0
- package/build/utils/tool-filter.js +0 -1
- package/build/utils/validate-glob.js +0 -1
- package/package.json +1 -1
- package/build/services/invoker.js +0 -72
package/README.md
CHANGED
|
@@ -145,8 +145,9 @@ startup, so you can keep secrets out of the config (see
|
|
|
145
145
|
|
|
146
146
|
> **Note on `-c` and credential storage:** when you override the config
|
|
147
147
|
> path with `-c /some/dir/mcp.json`, both `credentials.json` (OAuth
|
|
148
|
-
> tokens) and `
|
|
149
|
-
>
|
|
148
|
+
> tokens) and `tools-cache.json` (cached tool schemas) will be stored
|
|
149
|
+
> in that directory — i.e. next to the config file you specified. The
|
|
150
|
+
> `.env` file, however, is loaded from the
|
|
150
151
|
> [configuration directory](#config-file-location) resolved by
|
|
151
152
|
> `MCP_COMPRESS_ROUTER_HOME` or the platform default, *not* from beside
|
|
152
153
|
> the explicit `-c` path. To co-locate `.env` with a custom config, set
|
|
@@ -367,8 +368,10 @@ npx mcp-compress-router@latest login my-http
|
|
|
367
368
|
This opens your browser to complete the authorization-code flow. Tokens
|
|
368
369
|
are stored in a separate `credentials.json` in the same directory as
|
|
369
370
|
`mcp.json` (with `0600` permissions on Unix), so you can safely
|
|
370
|
-
share or version-control `mcp.json` without exposing tokens.
|
|
371
|
-
`
|
|
371
|
+
share or version-control `mcp.json` without exposing tokens. Cached
|
|
372
|
+
tool schemas are stored in `tools-cache.json` in the same directory.
|
|
373
|
+
Add both `credentials.json` and `tools-cache.json` to your
|
|
374
|
+
`.gitignore`.
|
|
372
375
|
|
|
373
376
|
By default the router uses
|
|
374
377
|
[Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591).
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { Logger } from '../utils/index.js';
|
|
1
2
|
import { ensureConfigDir, readConfigFile } from './config-io.js';
|
|
2
3
|
import { loadConfig } from '../services/config.js';
|
|
3
|
-
import { discoverAuth } from '../services/index.js';
|
|
4
|
+
import { discoverAuth, discoverSingleServer, saveToolCache } from '../services/index.js';
|
|
4
5
|
/**
|
|
5
6
|
* Validates that a server exists in config and is eligible for OAuth login.
|
|
6
7
|
*
|
|
@@ -260,5 +261,16 @@ export async function handleLogin(configPath, name, portOverride) {
|
|
|
260
261
|
redirectUri: realRedirectUrl,
|
|
261
262
|
});
|
|
262
263
|
await mgr.saveTokens(tokens);
|
|
264
|
+
// Best-effort: discover tools and save to cache so the next router
|
|
265
|
+
// startup (or self-recovery) has an up-to-date cache.
|
|
266
|
+
try {
|
|
267
|
+
const logger = new Logger('error');
|
|
268
|
+
const discovered = await discoverSingleServer(targetServer, logger, () => mgr);
|
|
269
|
+
await saveToolCache(configPath, name, discovered.tools);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// Best-effort — tokens are saved, cache will refresh on next
|
|
273
|
+
// router startup.
|
|
274
|
+
}
|
|
263
275
|
return `Successfully authenticated server "${name}". Tokens stored in credentials.json.`;
|
|
264
276
|
}
|
|
@@ -1,31 +1,53 @@
|
|
|
1
|
-
import
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { resolveConfigPath, persistAuthRequirements, loadConfig, ServerConnection, invokeWithRecovery, buildCatalog, ShutdownCoordinator, installShutdownTriggers, } from '../services/index.js';
|
|
2
3
|
import { Logger } from '../utils/index.js';
|
|
3
4
|
import { createGetToolSchemaHandler, buildGetToolSchemaDescription, GetToolSchemaInputSchema, createInvokeToolHandler, InvokeToolInputSchema, } from '../tools/index.js';
|
|
4
5
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Connects to all enabled downstream servers via ServerConnection.
|
|
9
|
+
* Disabled servers are skipped entirely. On failure, warm-cache
|
|
10
|
+
* servers are degraded; cold-cache servers cause fail-fast.
|
|
11
|
+
*
|
|
12
|
+
* @param servers - Validated downstream server configs.
|
|
13
|
+
* @param configPath - Absolute path to the config file.
|
|
14
|
+
* @param logger - Structured logger.
|
|
15
|
+
* @returns Map of server name to ServerConnection and discovered data.
|
|
16
|
+
* @throws When a server cannot connect AND has no tool cache.
|
|
9
17
|
*/
|
|
10
|
-
async function
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const hasCredentials = (await mgr.tokens()) !== undefined;
|
|
16
|
-
const hasOverrides = server.oauth?.clientId !== undefined;
|
|
17
|
-
if (hasCredentials || hasOverrides) {
|
|
18
|
-
authProviders.set(server.name, mgr);
|
|
19
|
-
}
|
|
18
|
+
async function connectAllServers(servers, configPath, logger) {
|
|
19
|
+
const enabledServers = servers.filter((server) => {
|
|
20
|
+
if (server.enabled === false) {
|
|
21
|
+
logger.info(`Skipping disabled server "${server.name}"`, { server: server.name });
|
|
22
|
+
return false;
|
|
20
23
|
}
|
|
24
|
+
return true;
|
|
25
|
+
});
|
|
26
|
+
const results = await Promise.all(enabledServers.map(async (server) => {
|
|
27
|
+
const conn = new ServerConnection(server, configPath, logger);
|
|
28
|
+
const ds = await conn.connect();
|
|
29
|
+
return { conn, ds };
|
|
30
|
+
}));
|
|
31
|
+
const connections = new Map();
|
|
32
|
+
const discovered = [];
|
|
33
|
+
for (const { conn, ds } of results) {
|
|
34
|
+
connections.set(conn.serverName, conn);
|
|
35
|
+
discovered.push(ds);
|
|
21
36
|
}
|
|
22
|
-
return
|
|
37
|
+
return { connections, discovered };
|
|
23
38
|
}
|
|
24
39
|
/**
|
|
25
40
|
* Creates the MCP server, registers router tools, and starts the
|
|
26
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.
|
|
27
49
|
*/
|
|
28
|
-
async function startRouterServer(catalog,
|
|
50
|
+
async function startRouterServer(catalog, connections, selectionByServer, logger) {
|
|
29
51
|
const router = new McpServer({
|
|
30
52
|
name: 'mcp-compress-router',
|
|
31
53
|
version: '1.0.0',
|
|
@@ -35,7 +57,9 @@ async function startRouterServer(catalog, clients, logger) {
|
|
|
35
57
|
description: buildGetToolSchemaDescription(catalog),
|
|
36
58
|
inputSchema: GetToolSchemaInputSchema,
|
|
37
59
|
}, createGetToolSchemaHandler(catalog, logger));
|
|
38
|
-
const invokeFn = (server, tool, args) =>
|
|
60
|
+
const invokeFn = async (server, tool, args) => {
|
|
61
|
+
return invokeWithRecovery(server, tool, args, catalog, connections, selectionByServer, logger);
|
|
62
|
+
};
|
|
39
63
|
router.registerTool('invoke_tool', {
|
|
40
64
|
title: 'Invoke Tool',
|
|
41
65
|
description: 'Invoke a specific tool on a connected MCP server. ' +
|
|
@@ -46,6 +70,29 @@ async function startRouterServer(catalog, clients, logger) {
|
|
|
46
70
|
const transport = new StdioServerTransport();
|
|
47
71
|
await router.connect(transport);
|
|
48
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(() => { })));
|
|
49
96
|
}
|
|
50
97
|
/**
|
|
51
98
|
* Runs the router: loads config, refreshes cached auth requirements,
|
|
@@ -66,17 +113,15 @@ export async function runRouter(configPath, verbose) {
|
|
|
66
113
|
const servers = await loadConfig(resolved);
|
|
67
114
|
logger.info('Configuration loaded', { serverCount: servers.length });
|
|
68
115
|
await persistAuthRequirements(resolved, servers, logger);
|
|
69
|
-
const authProviders = await buildAuthProviders(resolved, servers);
|
|
70
|
-
const getAuthProvider = (server) => authProviders.get(server.name);
|
|
71
116
|
logger.info('Connecting to downstream servers', {
|
|
72
117
|
servers: servers.map((s) => s.name),
|
|
73
118
|
});
|
|
74
|
-
const {
|
|
119
|
+
const { connections, discovered } = await connectAllServers(servers, resolved, logger);
|
|
75
120
|
logger.info('Tools discovered', {
|
|
76
121
|
servers: discovered.map((d) => ({
|
|
77
122
|
name: d.name,
|
|
78
123
|
toolCount: d.tools.length,
|
|
79
|
-
|
|
124
|
+
status: d.status,
|
|
80
125
|
})),
|
|
81
126
|
});
|
|
82
127
|
const selectionByServer = new Map();
|
|
@@ -89,5 +134,15 @@ export async function runRouter(configPath, verbose) {
|
|
|
89
134
|
compressionLevelByServer.set(server.name, server.compressionLevel);
|
|
90
135
|
}
|
|
91
136
|
const catalog = buildCatalog(discovered, selectionByServer, logger, compressionLevelByServer);
|
|
92
|
-
await startRouterServer(catalog,
|
|
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);
|
|
93
148
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tagged error thrown when a downstream server requires OAuth
|
|
3
|
+
* authentication that cannot be completed from the running router
|
|
4
|
+
* process.
|
|
5
|
+
*
|
|
6
|
+
* The MCP SDK calls `OAuthClientProvider.redirectToAuthorization()` on
|
|
7
|
+
* a 401 response. This error is thrown from that method so callers
|
|
8
|
+
* can discriminate auth failures from other errors (network, config,
|
|
9
|
+
* transport) without relying on substring matching of error messages.
|
|
10
|
+
*/
|
|
11
|
+
export class GuidedAuthError extends Error {
|
|
12
|
+
/** The downstream server name that requires authentication. */
|
|
13
|
+
serverName;
|
|
14
|
+
/**
|
|
15
|
+
* @param serverName - The name of the server requiring authentication.
|
|
16
|
+
*/
|
|
17
|
+
constructor(serverName) {
|
|
18
|
+
super(`Authentication required for server "${serverName}".`);
|
|
19
|
+
this.name = 'GuidedAuthError';
|
|
20
|
+
this.serverName = serverName;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -41,10 +41,54 @@ export function buildCatalog(discovered, selectionByServer = new Map(), logger,
|
|
|
41
41
|
description: ds.description,
|
|
42
42
|
tools: exposed,
|
|
43
43
|
compressionLevel: compressionLevelByServer.get(ds.name) ?? 'high',
|
|
44
|
+
status: ds.status ?? 'ok',
|
|
44
45
|
};
|
|
45
46
|
});
|
|
46
47
|
return { servers, toolMap, filteredToolNames };
|
|
47
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Updates a single server's tools and status in an existing catalog
|
|
51
|
+
* (mutates in place). Removes old `toolMap` entries for the server,
|
|
52
|
+
* re-applies the tool filter, and inserts the new entries.
|
|
53
|
+
*
|
|
54
|
+
* Used by `invokeWithRecovery` after a successful reconnect to make
|
|
55
|
+
* the freshly-discovered tools visible to `get_tool_schema` and
|
|
56
|
+
* `invoke_tool` without restarting the router.
|
|
57
|
+
*
|
|
58
|
+
* @param catalog - The tool catalog to mutate.
|
|
59
|
+
* @param serverName - The server to update.
|
|
60
|
+
* @param tools - The newly-discovered tool descriptors.
|
|
61
|
+
* @param status - The new connection status.
|
|
62
|
+
* @param toolSelection - Optional allowlist/denylist to re-apply.
|
|
63
|
+
*/
|
|
64
|
+
export function updateServerInCatalog(catalog, serverName, tools, status, toolSelection) {
|
|
65
|
+
const server = catalog.servers.find((s) => s.name === serverName);
|
|
66
|
+
if (!server) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const prefix = `${serverName}::`;
|
|
70
|
+
for (const key of [...catalog.toolMap.keys()]) {
|
|
71
|
+
if (key.startsWith(prefix)) {
|
|
72
|
+
catalog.toolMap.delete(key);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const key of [...catalog.filteredToolNames]) {
|
|
76
|
+
if (key.startsWith(prefix)) {
|
|
77
|
+
catalog.filteredToolNames.delete(key);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const { exposed, entries } = filterTools(tools, toolSelection?.allowedTools, toolSelection?.disabledTools);
|
|
81
|
+
for (const tool of exposed) {
|
|
82
|
+
catalog.toolMap.set(`${serverName}::${tool.name}`, tool);
|
|
83
|
+
}
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
if (entry.decision === 'filtered') {
|
|
86
|
+
catalog.filteredToolNames.add(`${serverName}::${entry.descriptor.name}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
server.tools = exposed;
|
|
90
|
+
server.status = status;
|
|
91
|
+
}
|
|
48
92
|
/**
|
|
49
93
|
* Looks up tool schemas in the catalog by server and tool names.
|
|
50
94
|
*
|
|
@@ -24,7 +24,7 @@ function isMethodNotFound(err) {
|
|
|
24
24
|
* @returns The list-tools result (possibly empty).
|
|
25
25
|
* @throws Any non-"Method not found" error from `listTools()`.
|
|
26
26
|
*/
|
|
27
|
-
async function listToolsOrEmpty(client) {
|
|
27
|
+
export async function listToolsOrEmpty(client) {
|
|
28
28
|
try {
|
|
29
29
|
return await client.listTools();
|
|
30
30
|
}
|
|
@@ -35,44 +35,12 @@ async function listToolsOrEmpty(client) {
|
|
|
35
35
|
throw err;
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
39
|
-
* Connects to all configured stdio servers in parallel and discovers
|
|
40
|
-
* their tools. Fails fast if any server is unreachable.
|
|
41
|
-
*
|
|
42
|
-
* @param servers - Validated downstream server configs.
|
|
43
|
-
* @param logger - Structured logger for diagnostic output.
|
|
44
|
-
* @param getAuthProvider - Optional factory to provide OAuth credentials for HTTP servers.
|
|
45
|
-
* @returns Discovered server data and live client connections.
|
|
46
|
-
* @throws If any server cannot be connected or tools cannot be listed.
|
|
47
|
-
*/
|
|
48
|
-
export async function connectAndDiscover(servers, logger, getAuthProvider) {
|
|
49
|
-
const enabledServers = [];
|
|
50
|
-
for (const server of servers) {
|
|
51
|
-
if (server.enabled === false) {
|
|
52
|
-
logger.info(`Skipping disabled server "${server.name}"`, {
|
|
53
|
-
server: server.name,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
else {
|
|
57
|
-
enabledServers.push(server);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
const results = await Promise.all(enabledServers.map((server) => connectSingleServer(server, logger, getAuthProvider)));
|
|
61
|
-
const serversList = [];
|
|
62
|
-
const clients = new Map();
|
|
63
|
-
for (const { server, client } of results) {
|
|
64
|
-
serversList.push(server);
|
|
65
|
-
clients.set(server.name, client);
|
|
66
|
-
}
|
|
67
|
-
return { servers: serversList, clients };
|
|
68
|
-
}
|
|
69
38
|
/**
|
|
70
39
|
* Connects to a single downstream server, lists its tools, and closes
|
|
71
|
-
* the connection. Unlike
|
|
40
|
+
* the connection. Unlike the router startup path, this function does
|
|
72
41
|
* NOT inspect the server's `enabled` flag — it always probes live. It
|
|
73
|
-
* is the engine behind the `tools <name>` inspection command
|
|
74
|
-
*
|
|
75
|
-
* server is the primary configuration workflow).
|
|
42
|
+
* is the engine behind the `tools <name>` inspection command and the
|
|
43
|
+
* `login` command's post-authentication cache refresh.
|
|
76
44
|
*
|
|
77
45
|
* The returned {@link DiscoveredServer} carries every advertised tool;
|
|
78
46
|
* callers apply the Tool Filter themselves to compute exposure marks.
|
|
@@ -81,25 +49,10 @@ export async function connectAndDiscover(servers, logger, getAuthProvider) {
|
|
|
81
49
|
* @param logger - Structured logger for diagnostic output.
|
|
82
50
|
* @param getAuthProvider - Optional factory to provide OAuth credentials
|
|
83
51
|
* for HTTP servers.
|
|
84
|
-
* @returns The discovered server data (name, description, tools).
|
|
52
|
+
* @returns The discovered server data (name, description, tools, status).
|
|
85
53
|
* @throws If the server cannot be connected or tools cannot be listed.
|
|
86
|
-
* @public
|
|
87
54
|
*/
|
|
88
55
|
export async function discoverSingleServer(server, logger, getAuthProvider) {
|
|
89
|
-
const { client, server: discovered } = await connectSingleServer(server, logger, getAuthProvider);
|
|
90
|
-
await client.close();
|
|
91
|
-
return discovered;
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* Connects to a single downstream server and discovers its tools.
|
|
95
|
-
*
|
|
96
|
-
* @param server - Downstream server configuration.
|
|
97
|
-
* @param logger - Structured logger for diagnostic output.
|
|
98
|
-
* @param getAuthProvider - Optional factory to provide OAuth credentials.
|
|
99
|
-
* @returns The discovered server data and live client connection.
|
|
100
|
-
* @throws If the server cannot be connected or tools cannot be listed.
|
|
101
|
-
*/
|
|
102
|
-
async function connectSingleServer(server, logger, getAuthProvider) {
|
|
103
56
|
const client = new Client({ name: 'mcp-compress-router', version: '1.0.0' }, { capabilities: {} });
|
|
104
57
|
logger.info(`Connecting to downstream server "${server.name}"`, {
|
|
105
58
|
server: server.name,
|
|
@@ -120,12 +73,10 @@ async function connectSingleServer(server, logger, getAuthProvider) {
|
|
|
120
73
|
inputSchema: t.inputSchema,
|
|
121
74
|
}));
|
|
122
75
|
return {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
},
|
|
128
|
-
client,
|
|
76
|
+
name: server.name,
|
|
77
|
+
description: server.description,
|
|
78
|
+
tools,
|
|
79
|
+
status: 'ok',
|
|
129
80
|
};
|
|
130
81
|
}
|
|
131
82
|
catch (err) {
|
|
@@ -137,6 +88,9 @@ async function connectSingleServer(server, logger, getAuthProvider) {
|
|
|
137
88
|
});
|
|
138
89
|
throw new Error(`Failed to connect to server "${server.name}": ${message}`);
|
|
139
90
|
}
|
|
91
|
+
finally {
|
|
92
|
+
await client.close().catch(() => { });
|
|
93
|
+
}
|
|
140
94
|
}
|
|
141
95
|
/**
|
|
142
96
|
* Creates the appropriate transport for a downstream server config.
|
|
@@ -146,7 +100,7 @@ async function connectSingleServer(server, logger, getAuthProvider) {
|
|
|
146
100
|
* @returns A configured transport instance (stdio or HTTP).
|
|
147
101
|
* @throws If required configuration (command or url) is missing.
|
|
148
102
|
*/
|
|
149
|
-
function createTransport(server, getAuthProvider) {
|
|
103
|
+
export function createTransport(server, getAuthProvider) {
|
|
150
104
|
if (server.type === 'stdio') {
|
|
151
105
|
if (!server.command) {
|
|
152
106
|
throw new Error(`Server "${server.name}" (stdio) is missing command`);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a detailed, multi-line error message for an unavailable
|
|
3
|
+
* downstream server, explaining what happened, showing the underlying
|
|
4
|
+
* error, and giving actionable steps including the restart fallback.
|
|
5
|
+
*
|
|
6
|
+
* The message format is:
|
|
7
|
+
*
|
|
8
|
+
* 1. Summary line: server name + what went wrong.
|
|
9
|
+
* 2. "What happened:" section.
|
|
10
|
+
* 3. "Underlying error:" — the original error message verbatim.
|
|
11
|
+
* 4. "To fix this:" — numbered, actionable steps tailored to the
|
|
12
|
+
* server type and status.
|
|
13
|
+
* 5. Restart fallback guidance.
|
|
14
|
+
*
|
|
15
|
+
* @param server - The downstream server configuration.
|
|
16
|
+
* @param underlyingError - The original error that caused the failure.
|
|
17
|
+
* @param status - The server's current status.
|
|
18
|
+
* @param recoveryAttempted - Whether automatic recovery was tried and
|
|
19
|
+
* failed. When true, an additional note is included.
|
|
20
|
+
* @returns An `Error` with the detailed message.
|
|
21
|
+
*/
|
|
22
|
+
export function buildGuidedError(server, underlyingError, status, recoveryAttempted) {
|
|
23
|
+
const underlyingMessage = extractMessage(underlyingError);
|
|
24
|
+
const isStdio = server.type === 'stdio';
|
|
25
|
+
const isAuth = status === 'unauthorized';
|
|
26
|
+
const lines = [];
|
|
27
|
+
lines.push(`Server "${server.name}" is unavailable: ${headline(server, status)}.`);
|
|
28
|
+
lines.push('');
|
|
29
|
+
lines.push('What happened:');
|
|
30
|
+
lines.push(bodyText(server, status, isStdio, isAuth));
|
|
31
|
+
lines.push('');
|
|
32
|
+
lines.push(`Underlying error: ${underlyingMessage}`);
|
|
33
|
+
if (recoveryAttempted) {
|
|
34
|
+
lines.push('');
|
|
35
|
+
lines.push('Automatic recovery was attempted but failed.');
|
|
36
|
+
}
|
|
37
|
+
lines.push('');
|
|
38
|
+
lines.push('To fix this:');
|
|
39
|
+
lines.push(...fixSteps(server, status, isStdio, isAuth));
|
|
40
|
+
lines.push('');
|
|
41
|
+
lines.push(restartGuidance(isStdio));
|
|
42
|
+
return new Error(lines.join('\n'));
|
|
43
|
+
}
|
|
44
|
+
function headline(server, status) {
|
|
45
|
+
if (status === 'unauthorized') {
|
|
46
|
+
return 'authentication is required';
|
|
47
|
+
}
|
|
48
|
+
if (server.type === 'stdio') {
|
|
49
|
+
return 'the downstream process could not start';
|
|
50
|
+
}
|
|
51
|
+
return 'connection failed';
|
|
52
|
+
}
|
|
53
|
+
function bodyText(server, status, isStdio, isAuth) {
|
|
54
|
+
if (isAuth) {
|
|
55
|
+
return (`The router could not connect to server "${server.name}" because ` +
|
|
56
|
+
`authentication is required or the stored credentials are invalid.`);
|
|
57
|
+
}
|
|
58
|
+
if (isStdio) {
|
|
59
|
+
return (`The router could not start server "${server.name}" because the ` +
|
|
60
|
+
`configured command failed to spawn or connect.`);
|
|
61
|
+
}
|
|
62
|
+
return `The router could not establish a connection to server "${server.name}".`;
|
|
63
|
+
}
|
|
64
|
+
function fixSteps(server, status, isStdio, isAuth) {
|
|
65
|
+
if (isAuth) {
|
|
66
|
+
return [
|
|
67
|
+
`1. Run: npx mcp-compress-router login ${server.name}`,
|
|
68
|
+
'2. Complete the browser authorization flow.',
|
|
69
|
+
'3. Try your request again — the router will automatically pick up the',
|
|
70
|
+
' new credentials and reconnect.',
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
if (isStdio) {
|
|
74
|
+
const cmd = server.command ?? '(unknown)';
|
|
75
|
+
return [
|
|
76
|
+
`1. Verify the command is installed: ${cmd}`,
|
|
77
|
+
'2. Check that the command is in your PATH.',
|
|
78
|
+
'3. If using npx, ensure the package is available.',
|
|
79
|
+
'4. If the configuration has changed, update mcp.json.',
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
return [
|
|
83
|
+
'1. Verify the server is running and reachable.',
|
|
84
|
+
'2. Check your network connection and configuration.',
|
|
85
|
+
'3. If the configuration has changed, update mcp.json.',
|
|
86
|
+
'',
|
|
87
|
+
'After fixing, try your request again — the router will attempt to',
|
|
88
|
+
'reconnect automatically.',
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
function restartGuidance(isStdio) {
|
|
92
|
+
const restartClause = isStdio
|
|
93
|
+
? 'Stdio servers cannot self-recover without a restart because the ' +
|
|
94
|
+
'process must be spawned anew.'
|
|
95
|
+
: 'If the issue persists, restart the MCP server in your coding agent.';
|
|
96
|
+
return (restartClause +
|
|
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.');
|
|
100
|
+
}
|
|
101
|
+
function extractMessage(err) {
|
|
102
|
+
if (err instanceof Error) {
|
|
103
|
+
return err.message;
|
|
104
|
+
}
|
|
105
|
+
return String(err);
|
|
106
|
+
}
|
package/build/services/index.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
export { buildCatalog, lookupTools } from './catalog.js';
|
|
2
2
|
export { resolveConfigDir, resolveConfigPath, loadConfig } from './config.js';
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
3
|
+
export { discoverSingleServer } from './discovery.js';
|
|
4
|
+
export { ServerConnection } from './server-connection.js';
|
|
5
|
+
export { ShutdownCoordinator } from './shutdown-coordinator.js';
|
|
6
|
+
export { installShutdownTriggers } from './shutdown-triggers.js';
|
|
7
|
+
export { invokeWithRecovery } from './invoke-with-recovery.js';
|
|
8
|
+
export { saveToolCache } from './tool-cache.js';
|
|
5
9
|
export { OAuthCredentialManager } from './oauth.js';
|
|
6
10
|
export { computeAuthStatus, persistAuthRequirements } from './auth-status.js';
|
|
7
11
|
export { discoverAuth } from './oauth-discovery.js';
|
|
12
|
+
export { GuidedAuthError } from './auth-errors.js';
|
|
13
|
+
export { buildGuidedError } from './guided-error.js';
|