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 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 `mcp.json` live in `/some/dir/` — i.e. next to the config
149
- > file you specified. The `.env` file, however, is loaded from the
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. Add
371
- `credentials.json` to your `.gitignore`.
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 { resolveConfigPath, invokeDownstreamTool, OAuthCredentialManager, persistAuthRequirements, loadConfig, connectAndDiscover, buildCatalog, } from '../services/index.js';
1
+ import process from 'node:process';
2
+ import { resolveConfigPath, persistAuthRequirements, loadConfig, ServerConnection, invokeWithRecovery, buildCatalog, ShutdownCoordinator, installShutdownTriggers, } from '../services/index.js';
2
3
  import { Logger } from '../utils/index.js';
3
4
  import { createGetToolSchemaHandler, buildGetToolSchemaDescription, GetToolSchemaInputSchema, createInvokeToolHandler, InvokeToolInputSchema, } from '../tools/index.js';
4
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
7
  /**
7
- * Creates OAuth credential managers for HTTP servers that have stored
8
- * credentials or oauth overrides, so the transport can handle auth.
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 buildAuthProviders(resolved, servers) {
11
- const authProviders = new Map();
12
- for (const server of servers) {
13
- if (server.type === 'http' || server.type === 'streamable-http') {
14
- const mgr = new OAuthCredentialManager(resolved, server);
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 authProviders;
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, clients, logger) {
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) => invokeDownstreamTool(clients, server, tool, args, logger);
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 { servers: discovered, clients } = await connectAndDiscover(servers, logger, getAuthProvider);
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
- tools: d.tools.map((t) => t.name),
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, clients, logger);
137
+ const closeRouter = await startRouterServer(catalog, connections, selectionByServer, logger);
138
+ const coordinator = new ShutdownCoordinator(logger);
139
+ coordinator.register(() => closeAllConnections(connections, logger));
140
+ coordinator.register(closeRouter);
141
+ installShutdownTriggers(coordinator, logger);
142
+ // Block here until a shutdown trigger fires (signal or stdin EOF), then
143
+ // force-exit so lingering grandchild pipes (e.g. browser processes
144
+ // forked by a downstream server) cannot keep the router alive. Without
145
+ // this, the router would linger as a ghost process forever.
146
+ await coordinator.whenShutdown();
147
+ process.exit(0);
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 {@link connectAndDiscover}, this function does
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, which
74
- * must work for disabled servers (building an allowlist for a disabled
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
- server: {
124
- name: server.name,
125
- description: server.description,
126
- tools,
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
+ }
@@ -1,7 +1,13 @@
1
1
  export { buildCatalog, lookupTools } from './catalog.js';
2
2
  export { resolveConfigDir, resolveConfigPath, loadConfig } from './config.js';
3
- export { connectAndDiscover, discoverSingleServer } from './discovery.js';
4
- export { invokeDownstreamTool } from './invoker.js';
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';