mcp-compress-router 1.0.0

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.
@@ -0,0 +1,113 @@
1
+ import { readCredentials, writeCredentials } from '../cli/config-io.js';
2
+ /**
3
+ * Probes a downstream HTTP server's OAuth discovery endpoint to
4
+ * determine whether it advertises OAuth support. Makes a single probe
5
+ * of the server's well-known authorization-server metadata.
6
+ *
7
+ * stdio servers never support OAuth, so they short-circuit to `'none'`
8
+ * without any network access.
9
+ *
10
+ * @param server - Typed downstream server config.
11
+ * @param logger - Optional logger for diagnostic output on probe errors.
12
+ * @returns `'oauth'` when metadata is advertised, `'none'` when it is
13
+ * absent, or `'unknown'` on network/probe errors.
14
+ * @internal Exported for tests only; not part of the public module API.
15
+ */
16
+ export async function probeAuthRequirement(server, logger) {
17
+ if (server.type === 'stdio') {
18
+ return 'none';
19
+ }
20
+ if (!server.url) {
21
+ return 'unknown';
22
+ }
23
+ try {
24
+ const { discoverAuthorizationServerMetadata } = await import('@modelcontextprotocol/sdk/client/auth.js');
25
+ const metadata = await discoverAuthorizationServerMetadata(new URL(server.url));
26
+ return metadata ? 'oauth' : 'none';
27
+ }
28
+ catch (err) {
29
+ logger?.error(`Failed to probe OAuth metadata for "${server.name}"`, {
30
+ server: server.name,
31
+ error: err instanceof Error ? err.message : String(err),
32
+ });
33
+ return 'unknown';
34
+ }
35
+ }
36
+ /**
37
+ * Determines the final display auth status for a server using only
38
+ * local (non-network) information: the cached auth requirement from
39
+ * `credentials.json`, any stored tokens, and configured HTTP headers.
40
+ *
41
+ * @param server - Typed downstream server config.
42
+ * @param stored - The server's entry from `credentials.json`, or
43
+ * `undefined` when no entry exists.
44
+ * @returns The auth status label shown in the `list` table.
45
+ */
46
+ export function computeAuthStatus(server, stored) {
47
+ if (server.type === 'stdio') {
48
+ return 'none';
49
+ }
50
+ // A static Authorization header takes precedence over OAuth state.
51
+ if (hasAuthorizationHeader(server.headers)) {
52
+ return 'header';
53
+ }
54
+ const requirement = stored?.authRequirement ?? 'unknown';
55
+ const hasTokens = Boolean(stored?.tokens?.access_token);
56
+ switch (requirement) {
57
+ case 'oauth':
58
+ return hasTokens ? 'authenticated' : 'requires login';
59
+ case 'none':
60
+ return 'public';
61
+ case 'unknown':
62
+ return 'unknown';
63
+ }
64
+ }
65
+ /**
66
+ * Returns true when the headers map contains an `Authorization` header
67
+ * (case-insensitive key match).
68
+ *
69
+ * @param headers - Optional HTTP headers map.
70
+ */
71
+ function hasAuthorizationHeader(headers) {
72
+ if (!headers) {
73
+ return false;
74
+ }
75
+ return Object.keys(headers).some((key) => key.toLowerCase() === 'authorization');
76
+ }
77
+ /**
78
+ * Probes every HTTP downstream server for OAuth metadata and caches the
79
+ * result in `credentials.json` so the `list` command can show auth
80
+ * status without any network access. stdio servers are skipped (they
81
+ * never support OAuth). Probe errors are recorded as `'unknown'` so a
82
+ * single flaky server never blocks startup.
83
+ *
84
+ * The network probes run in parallel for speed, but the credential file
85
+ * writes are serialized to avoid concurrent read-modify-write races.
86
+ *
87
+ * @param configPath - Absolute path to the mcp.json file.
88
+ * @param servers - Typed downstream server configs.
89
+ * @param logger - Logger for diagnostic output.
90
+ */
91
+ export async function persistAuthRequirements(configPath, servers, logger) {
92
+ // Read the existing store once so per-server tokens and client
93
+ // registration survive the auth-requirement update.
94
+ const existing = await readCredentials(configPath);
95
+ const httpServers = servers.filter((server) => server.type !== 'stdio');
96
+ // Network-bound: probe in parallel for speed.
97
+ const results = await Promise.all(httpServers.map(async (server) => ({
98
+ name: server.name,
99
+ requirement: await probeAuthRequirement(server, logger),
100
+ })));
101
+ const checkedAt = new Date().toISOString();
102
+ // Serialize writes to avoid concurrent file read-modify-write races.
103
+ for (const { name, requirement } of results) {
104
+ await writeCredentials(configPath, name, {
105
+ ...existing[name],
106
+ authRequirement: requirement,
107
+ checkedAt,
108
+ });
109
+ }
110
+ logger.debug('Cached auth requirements', {
111
+ servers: Object.fromEntries(results.map((r) => [r.name, r.requirement])),
112
+ });
113
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Builds the immutable tool catalog from discovered server data.
3
+ *
4
+ * @param discovered - Results from parallel discovery.
5
+ * @returns An immutable ToolCatalog.
6
+ */
7
+ export function buildCatalog(discovered) {
8
+ const toolMap = new Map();
9
+ const servers = discovered.map((ds) => {
10
+ for (const tool of ds.tools) {
11
+ const key = `${ds.name}::${tool.name}`;
12
+ toolMap.set(key, tool);
13
+ }
14
+ return {
15
+ name: ds.name,
16
+ description: ds.description,
17
+ tools: ds.tools,
18
+ };
19
+ });
20
+ return { servers, toolMap };
21
+ }
22
+ /**
23
+ * Looks up tool schemas in the catalog by server and tool names.
24
+ *
25
+ * @param catalog - The tool catalog.
26
+ * @param serverName - The server to look up.
27
+ * @param toolNames - The tool names to retrieve.
28
+ * @returns The matching tool descriptors.
29
+ * @throws If the server or any tool is not found.
30
+ */
31
+ export function lookupTools(catalog, serverName, toolNames) {
32
+ const server = catalog.servers.find((s) => s.name === serverName);
33
+ if (!server) {
34
+ const available = catalog.servers.map((s) => s.name).join(', ');
35
+ throw new Error(`Server "${serverName}" not found. Available servers: ${available}`);
36
+ }
37
+ const results = [];
38
+ const missing = [];
39
+ for (const toolName of toolNames) {
40
+ const key = `${serverName}::${toolName}`;
41
+ const tool = catalog.toolMap.get(key);
42
+ if (!tool) {
43
+ missing.push(toolName);
44
+ }
45
+ else {
46
+ results.push(tool);
47
+ }
48
+ }
49
+ if (missing.length > 0) {
50
+ const valid = server.tools.map((t) => t.name).join(', ');
51
+ throw new Error(`Tool(s) not found on server "${serverName}": ${missing.join(', ')}. Valid tools: ${valid}`);
52
+ }
53
+ return results;
54
+ }
@@ -0,0 +1,273 @@
1
+ import * as path from 'node:path';
2
+ import * as os from 'node:os';
3
+ import * as fs from 'node:fs/promises';
4
+ import { expandEnvField, parseJsonc } from '../utils/index.js';
5
+ /** Recognized MCP transport types. */
6
+ const VALID_TYPES = new Set(['stdio', 'http', 'streamable-http']);
7
+ /** Per-application directory name used inside the user data folder. */
8
+ const APP_DIR_NAME = 'mcp-compress-router';
9
+ /**
10
+ * Resolves the base configuration directory.
11
+ *
12
+ * Priority: (1) MCP_COMPRESS_ROUTER_HOME env var, (2) the
13
+ * platform-specific default (see {@link defaultConfigDir}). This is a
14
+ * synchronous function that only reads environment variables and
15
+ * performs path operations.
16
+ *
17
+ * @returns The absolute path to the configuration directory.
18
+ */
19
+ export function resolveConfigDir() {
20
+ return process.env.MCP_COMPRESS_ROUTER_HOME ?? defaultConfigDir();
21
+ }
22
+ /**
23
+ * Returns the platform-specific default configuration directory.
24
+ *
25
+ * - Windows (`win32`): `%APPDATA%\mcp-compress-router` (falls back to
26
+ * `%USERPROFILE%\AppData\Roaming\mcp-compress-router` when `APPDATA`
27
+ * is unset).
28
+ * - macOS (`darwin`): `~/Library/Application Support/mcp-compress-router`.
29
+ * - Linux and other Unixes: `~/.local/share/mcp-compress-router`
30
+ * (XDG Base Directory style).
31
+ *
32
+ * The function is pure and side-effect free so it can be unit tested
33
+ * for every platform regardless of the host the tests run on.
34
+ *
35
+ * @param platform - The Node platform identifier. Defaults to the host's.
36
+ * @param home - The user home directory. Defaults to the host's.
37
+ * @param appData - The Windows `%APPDATA%` path. Defaults to the env var.
38
+ * @returns The absolute path to the default configuration directory.
39
+ * @internal Exported for tests only; not part of the public module API.
40
+ */
41
+ export function defaultConfigDir(platform = process.platform, home = os.homedir(), appData = process.env.APPDATA) {
42
+ switch (platform) {
43
+ case 'win32':
44
+ return path.join(appData ?? path.join(home, 'AppData', 'Roaming'), APP_DIR_NAME);
45
+ case 'darwin':
46
+ return path.join(home, 'Library', 'Application Support', APP_DIR_NAME);
47
+ default:
48
+ return path.join(home, '.local', 'share', APP_DIR_NAME);
49
+ }
50
+ }
51
+ /**
52
+ * Resolves the configuration file path.
53
+ *
54
+ * Priority: (1) explicit path argument, (2) MCP_COMPRESS_ROUTER_HOME env var,
55
+ * (3) the platform-specific default (see {@link defaultConfigDir}). Within the
56
+ * resolved directory, `mcp.jsonc` is preferred over `mcp.json` when both exist.
57
+ * If neither exists, returns `mcp.json` (created on first use).
58
+ *
59
+ * @param explicitPath - An explicit config path, or undefined.
60
+ * @returns The resolved absolute path to the config file.
61
+ */
62
+ export async function resolveConfigPath(explicitPath) {
63
+ if (explicitPath !== undefined) {
64
+ return explicitPath;
65
+ }
66
+ return resolveDirectoryConfig(resolveConfigDir());
67
+ }
68
+ /**
69
+ * Returns the existing mcp.jsonc or mcp.json path from a directory,
70
+ * preferring jsonc. When neither file exists, returns mcp.json.
71
+ */
72
+ async function resolveDirectoryConfig(dir) {
73
+ const jsoncPath = path.join(dir, 'mcp.jsonc');
74
+ const jsonPath = path.join(dir, 'mcp.json');
75
+ if (await pathExists(jsoncPath))
76
+ return jsoncPath;
77
+ if (await pathExists(jsonPath))
78
+ return jsonPath;
79
+ return jsonPath;
80
+ }
81
+ /**
82
+ * Checks whether a file exists at the given path.
83
+ */
84
+ async function pathExists(filePath) {
85
+ try {
86
+ await fs.access(filePath);
87
+ return true;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ /**
94
+ * Validates the "type" field and per-type required fields for a server.
95
+ *
96
+ * @param name - Server name (for error messages).
97
+ * @param server - The server entry object from mcp.json.
98
+ * @returns The validated transport type.
99
+ * @throws If type is missing, unsupported, or required fields are absent.
100
+ */
101
+ function validateServerType(name, server) {
102
+ const rawType = server.type;
103
+ if (typeof rawType !== 'string' || rawType.length === 0) {
104
+ throw new Error(`Server "${name}" is missing required "type" field. Must be one of: ${[...VALID_TYPES].join(', ')}`);
105
+ }
106
+ if (!VALID_TYPES.has(rawType)) {
107
+ throw new Error(`Server "${name}" has unsupported type "${rawType}". Must be one of: ${[...VALID_TYPES].join(', ')}`);
108
+ }
109
+ const type = rawType;
110
+ if (type === 'stdio') {
111
+ if (typeof server.command !== 'string' || server.command.length === 0) {
112
+ throw new Error(`Server "${name}" (stdio) is missing required "command" field`);
113
+ }
114
+ }
115
+ else {
116
+ if (typeof server.url !== 'string' || server.url.length === 0) {
117
+ throw new Error(`Server "${name}" (${type}) is missing required "url" field`);
118
+ }
119
+ }
120
+ return type;
121
+ }
122
+ /**
123
+ * Converts a Record<string, unknown> to Record<string, string> by coercing
124
+ * each value to a string, or returns undefined when the input is missing.
125
+ *
126
+ * @param obj - The raw object or undefined/null.
127
+ * @returns The string map or undefined.
128
+ */
129
+ function toStringMap(obj) {
130
+ if (obj === undefined || obj === null || typeof obj !== 'object') {
131
+ return undefined;
132
+ }
133
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, String(v)]));
134
+ }
135
+ /**
136
+ * Expands env vars in every value of a string map.
137
+ *
138
+ * @param map - The string map to expand.
139
+ * @param keyContext - Context prefix for expansion error messages (e.g. "env").
140
+ * @param entryContext - Server-level context (e.g. 'server "github"').
141
+ * @returns The expanded string map.
142
+ */
143
+ function expandStringMap(map, keyContext, entryContext) {
144
+ return Object.fromEntries(Object.entries(map).map(([k, v]) => [
145
+ k,
146
+ expandEnvField(v, `${entryContext} ${keyContext}.${k}`),
147
+ ]));
148
+ }
149
+ /**
150
+ * Extracts and expands raw fields from a server entry.
151
+ *
152
+ * @param entryContext - Human-readable context string for env var expansion.
153
+ * @param server - The server entry object from mcp.json.
154
+ * @returns An object containing expanded command, args, env, url, and headers.
155
+ */
156
+ function buildServerFields(entryContext, server) {
157
+ const rawCommand = typeof server.command === 'string' ? server.command : undefined;
158
+ const rawArgs = Array.isArray(server.args)
159
+ ? server.args.map(String)
160
+ : undefined;
161
+ const rawEnv = toStringMap(server.env);
162
+ const rawUrl = typeof server.url === 'string' ? server.url : undefined;
163
+ const rawHeaders = toStringMap(server.headers);
164
+ const command = rawCommand !== undefined ? expandEnvField(rawCommand, `${entryContext} command`) : undefined;
165
+ const args = rawArgs?.map((a, i) => expandEnvField(a, `${entryContext} args[${i}]`));
166
+ const env = rawEnv !== undefined ? expandStringMap(rawEnv, 'env', entryContext) : undefined;
167
+ const url = rawUrl !== undefined ? expandEnvField(rawUrl, `${entryContext} url`) : undefined;
168
+ const headers = rawHeaders !== undefined ? expandStringMap(rawHeaders, 'headers', entryContext) : undefined;
169
+ return { command, args, env, url, headers };
170
+ }
171
+ /**
172
+ * Parses the optional `oauth` block from a server entry with env expansion.
173
+ *
174
+ * @param entryContext - Human-readable context string for env var expansion.
175
+ * @param server - The server entry object from mcp.json.
176
+ * @returns The parsed OAuth config, or undefined if absent.
177
+ * @throws If the oauth block is not an object.
178
+ */
179
+ function parseOauthBlock(entryContext, server) {
180
+ if (server.oauth === undefined || server.oauth === null) {
181
+ return undefined;
182
+ }
183
+ if (typeof server.oauth !== 'object') {
184
+ throw new Error(`Server "${entryContext.slice(8)}" oauth block must be an object`);
185
+ }
186
+ const oauthRaw = server.oauth;
187
+ const rawClientId = typeof oauthRaw.clientId === 'string' ? oauthRaw.clientId : undefined;
188
+ const rawClientSecret = typeof oauthRaw.clientSecret === 'string' ? oauthRaw.clientSecret : undefined;
189
+ const rawScope = typeof oauthRaw.scope === 'string' ? oauthRaw.scope : undefined;
190
+ const clientId = rawClientId !== undefined
191
+ ? expandEnvField(rawClientId, `${entryContext} oauth.clientId`)
192
+ : undefined;
193
+ const clientSecret = rawClientSecret !== undefined
194
+ ? expandEnvField(rawClientSecret, `${entryContext} oauth.clientSecret`)
195
+ : undefined;
196
+ const scope = rawScope !== undefined ? expandEnvField(rawScope, `${entryContext} oauth.scope`) : undefined;
197
+ const oauth = {};
198
+ if (clientId !== undefined)
199
+ oauth.clientId = clientId;
200
+ if (clientSecret !== undefined)
201
+ oauth.clientSecret = clientSecret;
202
+ if (scope !== undefined)
203
+ oauth.scope = scope;
204
+ return oauth;
205
+ }
206
+ /**
207
+ * Parses a single server entry from the mcpServers object.
208
+ *
209
+ * Orchestrates validation, field extraction, and env var expansion for
210
+ * one named server entry, then returns the resulting config object.
211
+ *
212
+ * @param name - Server name (key in mcpServers).
213
+ * @param entry - The raw server value.
214
+ * @param names - Set of already-seen names for duplicate detection.
215
+ * @returns A validated DownstreamServerConfig.
216
+ * @throws If the entry is invalid or the name is a duplicate.
217
+ */
218
+ function parseServerEntry(name, entry, names) {
219
+ if (typeof entry !== 'object' || entry === null) {
220
+ throw new Error(`Server "${name}" must be an object`);
221
+ }
222
+ const server = entry;
223
+ const type = validateServerType(name, server);
224
+ if (names.has(name)) {
225
+ throw new Error(`Duplicate server name: "${name}"`);
226
+ }
227
+ names.add(name);
228
+ const entryContext = `server "${name}"`;
229
+ const fields = buildServerFields(entryContext, server);
230
+ const description = typeof server.description === 'string' ? server.description : undefined;
231
+ const oauth = parseOauthBlock(entryContext, server);
232
+ return { name, type, ...fields, description, oauth };
233
+ }
234
+ /**
235
+ * Loads and validates the MCP configuration file.
236
+ *
237
+ * Reads a JSON file with a top-level mcpServers object, extracts stdio
238
+ * entries, and validates uniqueness. HTTP servers and variable expansion
239
+ * are deferred to later slices.
240
+ *
241
+ * @param configPath - Absolute path to the mcp.json file.
242
+ * @returns An array of validated DownstreamServerConfig objects.
243
+ * @throws If the file is missing, invalid JSON, contains duplicate names,
244
+ * or contains zero servers (the router has nothing to route).
245
+ */
246
+ export async function loadConfig(configPath) {
247
+ const raw = await fs.readFile(configPath, 'utf-8');
248
+ let parsed;
249
+ try {
250
+ parsed = parseJsonc(raw, configPath);
251
+ }
252
+ catch {
253
+ throw new Error(`Failed to parse config file: ${configPath}`);
254
+ }
255
+ if (typeof parsed !== 'object' || parsed === null) {
256
+ throw new Error('Config file must contain a JSON object with mcpServers');
257
+ }
258
+ const obj = parsed;
259
+ const mcpServers = obj.mcpServers;
260
+ if (typeof mcpServers !== 'object' || mcpServers === null) {
261
+ throw new Error('Config file must contain an mcpServers object');
262
+ }
263
+ const names = new Set();
264
+ const servers = [];
265
+ for (const [name, entry] of Object.entries(mcpServers)) {
266
+ servers.push(parseServerEntry(name, entry, names));
267
+ }
268
+ if (servers.length === 0) {
269
+ throw new Error('Configuration contains no downstream MCP servers. Add at least one ' +
270
+ 'server before starting the router (e.g. via the "add" subcommand).');
271
+ }
272
+ return servers;
273
+ }
@@ -0,0 +1,104 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
3
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
+ /**
5
+ * Connects to all configured stdio servers in parallel and discovers
6
+ * their tools. Fails fast if any server is unreachable.
7
+ *
8
+ * @param servers - Validated downstream server configs.
9
+ * @param logger - Structured logger for diagnostic output.
10
+ * @param getAuthProvider - Optional factory to provide OAuth credentials for HTTP servers.
11
+ * @returns Discovered server data and live client connections.
12
+ * @throws If any server cannot be connected or tools cannot be listed.
13
+ */
14
+ export async function connectAndDiscover(servers, logger, getAuthProvider) {
15
+ const results = await Promise.all(servers.map((server) => connectSingleServer(server, logger, getAuthProvider)));
16
+ const serversList = [];
17
+ const clients = new Map();
18
+ for (const { server, client } of results) {
19
+ serversList.push(server);
20
+ clients.set(server.name, client);
21
+ }
22
+ return { servers: serversList, clients };
23
+ }
24
+ /**
25
+ * Connects to a single downstream server and discovers its tools.
26
+ *
27
+ * @param server - Downstream server configuration.
28
+ * @param logger - Structured logger for diagnostic output.
29
+ * @param getAuthProvider - Optional factory to provide OAuth credentials.
30
+ * @returns The discovered server data and live client connection.
31
+ * @throws If the server cannot be connected or tools cannot be listed.
32
+ */
33
+ async function connectSingleServer(server, logger, getAuthProvider) {
34
+ const client = new Client({ name: 'mcp-compress-router', version: '1.0.0' }, { capabilities: {} });
35
+ logger.info(`Connecting to downstream server "${server.name}"`, {
36
+ server: server.name,
37
+ type: server.type,
38
+ });
39
+ const transport = createTransport(server, getAuthProvider);
40
+ try {
41
+ await client.connect(transport);
42
+ const listResult = await client.listTools();
43
+ logger.info(`Connected to "${server.name}" — ${listResult.tools.length} tools discovered`, {
44
+ server: server.name,
45
+ toolCount: listResult.tools.length,
46
+ tools: listResult.tools.map((t) => t.name),
47
+ });
48
+ const tools = listResult.tools.map((t) => ({
49
+ name: t.name,
50
+ description: t.description,
51
+ inputSchema: t.inputSchema,
52
+ }));
53
+ return {
54
+ server: {
55
+ name: server.name,
56
+ description: server.description,
57
+ tools,
58
+ },
59
+ client,
60
+ };
61
+ }
62
+ catch (err) {
63
+ const message = err instanceof Error ? err.message : String(err);
64
+ logger.error(`Failed to connect to server "${server.name}"`, {
65
+ server: server.name,
66
+ type: server.type,
67
+ error: message,
68
+ });
69
+ throw new Error(`Failed to connect to server "${server.name}": ${message}`);
70
+ }
71
+ }
72
+ /**
73
+ * Creates the appropriate transport for a downstream server config.
74
+ *
75
+ * @param server - Downstream server configuration.
76
+ * @param getAuthProvider - Optional factory to provide OAuth credentials.
77
+ * @returns A configured transport instance (stdio or HTTP).
78
+ * @throws If required configuration (command or url) is missing.
79
+ */
80
+ function createTransport(server, getAuthProvider) {
81
+ if (server.type === 'stdio') {
82
+ if (!server.command) {
83
+ throw new Error(`Server "${server.name}" (stdio) is missing command`);
84
+ }
85
+ return new StdioClientTransport({
86
+ command: server.command,
87
+ args: server.args,
88
+ env: server.env,
89
+ });
90
+ }
91
+ // http or streamable-http
92
+ if (!server.url) {
93
+ throw new Error(`Server "${server.name}" (${server.type}) is missing url`);
94
+ }
95
+ const requestInit = {};
96
+ if (server.headers) {
97
+ requestInit.headers = server.headers;
98
+ }
99
+ const authProvider = getAuthProvider?.(server);
100
+ return new StreamableHTTPClientTransport(new URL(server.url), {
101
+ requestInit: Object.keys(requestInit).length > 0 ? requestInit : undefined,
102
+ authProvider,
103
+ });
104
+ }
@@ -0,0 +1,6 @@
1
+ export { buildCatalog, lookupTools } from './catalog.js';
2
+ export { resolveConfigDir, resolveConfigPath, loadConfig } from './config.js';
3
+ export { connectAndDiscover } from './discovery.js';
4
+ export { invokeDownstreamTool } from './invoker.js';
5
+ export { OAuthCredentialManager } from './oauth.js';
6
+ export { computeAuthStatus, persistAuthRequirements } from './auth-status.js';
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Checks whether an error message indicates an authentication failure.
3
+ *
4
+ * @param message - The error message string to inspect.
5
+ * @returns `true` when the message contains an auth-related keyword.
6
+ */
7
+ function isAuthError(message) {
8
+ return (message.includes('Unauthorized') || message.includes('unauthorized') || message.includes('auth'));
9
+ }
10
+ /**
11
+ * Looks up a downstream MCP client by server name.
12
+ *
13
+ * @param clients - Map of server name to live MCP client.
14
+ * @param serverName - The downstream server name.
15
+ * @param logger - Structured logger for diagnostic output.
16
+ * @returns The matching client.
17
+ * @throws When no client is registered for the given server name.
18
+ */
19
+ function getClient(clients, serverName, logger) {
20
+ const client = clients.get(serverName);
21
+ if (!client) {
22
+ logger.error('Downstream server not found for invocation', {
23
+ server: serverName,
24
+ availableServers: [...clients.keys()],
25
+ });
26
+ throw new Error(`Server "${serverName}" not found. Available servers: ${[...clients.keys()].join(', ')}`);
27
+ }
28
+ return client;
29
+ }
30
+ /**
31
+ * Invokes a tool on a downstream MCP server and returns the result
32
+ * verbatim.
33
+ *
34
+ * @param clients - Map of server name to live MCP client.
35
+ * @param serverName - The downstream server name.
36
+ * @param toolName - The tool to invoke.
37
+ * @param args - The arguments to pass to the tool.
38
+ * @param logger - Structured logger for diagnostic output.
39
+ * @returns The downstream call result verbatim (content blocks
40
+ * unchanged in structure and order).
41
+ */
42
+ export async function invokeDownstreamTool(clients, serverName, toolName, args, logger) {
43
+ logger.debug('Forwarding invocation to downstream server', {
44
+ server: serverName,
45
+ tool: toolName,
46
+ arguments: args,
47
+ });
48
+ const client = getClient(clients, serverName, logger);
49
+ try {
50
+ const result = (await client.callTool({ name: toolName, arguments: args }));
51
+ logger.debug('Downstream invocation result', {
52
+ server: serverName,
53
+ tool: toolName,
54
+ isError: result.isError ?? false,
55
+ contentBlockCount: result.content.length,
56
+ });
57
+ return result;
58
+ }
59
+ catch (err) {
60
+ const message = err instanceof Error ? err.message : String(err);
61
+ if (isAuthError(message)) {
62
+ logger.error('Downstream server authentication failed', {
63
+ server: serverName,
64
+ tool: toolName,
65
+ error: message,
66
+ });
67
+ throw new Error(`Authentication failed for server "${serverName}". ` +
68
+ `Run "mcp-compress-router login ${serverName}" to re-authenticate.`);
69
+ }
70
+ throw err;
71
+ }
72
+ }