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.
- package/LICENSE +7 -0
- package/README.md +51 -0
- package/build/catalog.js +54 -0
- package/build/cli/add-command.js +107 -0
- package/build/cli/config-io.js +213 -0
- package/build/cli/config-path-note.js +21 -0
- package/build/cli/get-command.js +49 -0
- package/build/cli/index.js +6 -0
- package/build/cli/list-command.js +79 -0
- package/build/cli/login-command.js +219 -0
- package/build/cli/logout-command.js +34 -0
- package/build/cli/remove-command.js +26 -0
- package/build/config.js +80 -0
- package/build/discovery.js +39 -0
- package/build/index.js +241 -0
- package/build/services/auth-status.js +113 -0
- package/build/services/catalog.js +54 -0
- package/build/services/config.js +273 -0
- package/build/services/discovery.js +104 -0
- package/build/services/index.js +6 -0
- package/build/services/invoker.js +72 -0
- package/build/services/oauth.js +145 -0
- package/build/text-format.js +24 -0
- package/build/tools/get-tool-schema.js +61 -0
- package/build/tools/index.js +2 -0
- package/build/tools/invoke-tool.js +92 -0
- package/build/types.js +1 -0
- package/build/utils/expand-env.js +47 -0
- package/build/utils/index.js +7 -0
- package/build/utils/logger.js +78 -0
- package/build/utils/open-browser.js +49 -0
- package/build/utils/parse-jsonc.js +24 -0
- package/build/utils/text-format.js +24 -0
- package/build/utils/types.js +1 -0
- package/build/utils/validate-arguments.js +156 -0
- package/package.json +71 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { ensureConfigDir, readConfigFile } from './config-io.js';
|
|
2
|
+
import { loadConfig } from '../services/config.js';
|
|
3
|
+
/**
|
|
4
|
+
* Validates that a server exists in config and is eligible for OAuth login.
|
|
5
|
+
*
|
|
6
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
7
|
+
* @param name - Server name to validate.
|
|
8
|
+
* @returns The raw server entry and the typed downstream config.
|
|
9
|
+
* @throws If the server is not found or is not an HTTP type.
|
|
10
|
+
*/
|
|
11
|
+
async function validateServerForLogin(configPath, name) {
|
|
12
|
+
await ensureConfigDir(configPath);
|
|
13
|
+
const servers = await readConfigFile(configPath);
|
|
14
|
+
if (!(name in servers)) {
|
|
15
|
+
const available = Object.keys(servers);
|
|
16
|
+
const hint = available.length > 0
|
|
17
|
+
? ` Available servers: ${available.join(', ')}`
|
|
18
|
+
: ' No servers configured.';
|
|
19
|
+
throw new Error(`Server "${name}" not found.${hint}`);
|
|
20
|
+
}
|
|
21
|
+
const entry = servers[name];
|
|
22
|
+
if (entry.type !== 'http' && entry.type !== 'streamable-http') {
|
|
23
|
+
throw new Error(`Server "${name}" is type "${entry.type}". OAuth is only supported for HTTP servers.`);
|
|
24
|
+
}
|
|
25
|
+
const allConfigs = await loadConfig(configPath);
|
|
26
|
+
const targetServer = allConfigs.find((s) => s.name === name);
|
|
27
|
+
if (!targetServer) {
|
|
28
|
+
throw new Error(`Server "${name}" not found in configuration.`);
|
|
29
|
+
}
|
|
30
|
+
return { entry, targetServer };
|
|
31
|
+
}
|
|
32
|
+
// Cached lazy imports for SDK OAuth modules.
|
|
33
|
+
let _sdkAuth;
|
|
34
|
+
async function _getSdkAuth() {
|
|
35
|
+
if (!_sdkAuth) {
|
|
36
|
+
_sdkAuth = await import('@modelcontextprotocol/sdk/client/auth.js');
|
|
37
|
+
}
|
|
38
|
+
return _sdkAuth;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Discovers OAuth metadata and registers the client if dynamic registration
|
|
42
|
+
* is needed.
|
|
43
|
+
*
|
|
44
|
+
* @param mgr - OAuth credential manager for the target server.
|
|
45
|
+
* @param targetServer - Typed downstream server configuration.
|
|
46
|
+
* @param name - Server name (for error messages).
|
|
47
|
+
* @returns The discovered OAuth metadata (non-null after validation).
|
|
48
|
+
* @throws If the server does not expose OAuth metadata or registration.
|
|
49
|
+
*/
|
|
50
|
+
async function setupOAuthClient(mgr, targetServer, name) {
|
|
51
|
+
const { discoverOAuthMetadata, registerClient } = await _getSdkAuth();
|
|
52
|
+
const serverUrl = new URL(targetServer.url);
|
|
53
|
+
const metadata = await discoverOAuthMetadata(serverUrl);
|
|
54
|
+
if (!metadata) {
|
|
55
|
+
throw new Error(`Server "${name}" does not expose OAuth metadata.`);
|
|
56
|
+
}
|
|
57
|
+
if (!metadata.registration_endpoint) {
|
|
58
|
+
throw new Error(`Server "${name}" does not support dynamic client registration.`);
|
|
59
|
+
}
|
|
60
|
+
if (!mgr.hasStaticClient() && !(await mgr.clientInformation())) {
|
|
61
|
+
const regResult = await registerClient(new URL(metadata.registration_endpoint), {
|
|
62
|
+
metadata,
|
|
63
|
+
clientMetadata: mgr.clientMetadata,
|
|
64
|
+
});
|
|
65
|
+
await mgr.saveClientInformation(regResult);
|
|
66
|
+
}
|
|
67
|
+
return metadata;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Starts a temporary HTTP server, opens the browser for authorization,
|
|
71
|
+
* and waits for the OAuth callback with a configurable timeout.
|
|
72
|
+
*
|
|
73
|
+
* @param mgr - OAuth credential manager.
|
|
74
|
+
* @param metadata - Discovered OAuth metadata.
|
|
75
|
+
* @param targetServer - Typed downstream server configuration.
|
|
76
|
+
* @returns The authorization code and the full `startAuthorization` result.
|
|
77
|
+
* @throws If the callback times out or the authorization is denied.
|
|
78
|
+
*/
|
|
79
|
+
async function acquireAuthorizationCode(mgr, metadata, targetServer) {
|
|
80
|
+
const { startAuthorization } = await _getSdkAuth();
|
|
81
|
+
const { openBrowser } = await import('../utils/open-browser.js');
|
|
82
|
+
const TIMEOUT_MS = _readTimeoutMs();
|
|
83
|
+
return _startCallbackServerAndWait(mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Creates the HTTP request listener for the temporary OAuth callback server.
|
|
87
|
+
*
|
|
88
|
+
* Uses `tempServer` captured via closure for calling `.close()` and the
|
|
89
|
+
* `resolve`/`reject` functions to settle the promise. The `timeoutHandle`
|
|
90
|
+
* wrapper allows the callback to clear the timeout when a response arrives.
|
|
91
|
+
*
|
|
92
|
+
* @param timeoutHandle - Mutable object wrapping the timeout ID.
|
|
93
|
+
* @param tempServer - The temporary HTTP server (to close on completion).
|
|
94
|
+
* @param resolve - Promise resolve function.
|
|
95
|
+
* @param reject - Promise reject function.
|
|
96
|
+
* @returns An HTTP request listener.
|
|
97
|
+
*/
|
|
98
|
+
function _makeCallbackHandler(timeoutHandle, tempServer, resolve, reject) {
|
|
99
|
+
return (req, res) => {
|
|
100
|
+
const url = new URL(req.url, `http://localhost`);
|
|
101
|
+
const code = url.searchParams.get('code');
|
|
102
|
+
const error = url.searchParams.get('error');
|
|
103
|
+
if (code) {
|
|
104
|
+
if (timeoutHandle.current)
|
|
105
|
+
clearTimeout(timeoutHandle.current);
|
|
106
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
107
|
+
res.end('<html><body><h1>Authorization successful!</h1><p>You can close this window.</p></body></html>');
|
|
108
|
+
tempServer.close();
|
|
109
|
+
resolve(code);
|
|
110
|
+
}
|
|
111
|
+
else if (error) {
|
|
112
|
+
if (timeoutHandle.current)
|
|
113
|
+
clearTimeout(timeoutHandle.current);
|
|
114
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
115
|
+
res.end(`<html><body><h1>Authorization failed</h1><p>${error}</p></body></html>`);
|
|
116
|
+
tempServer.close();
|
|
117
|
+
reject(new Error(`Authorization failed: ${error}`));
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
res.writeHead(404);
|
|
121
|
+
res.end('Not found');
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** Creates a temp HTTP server, starts OAuth flow, waits for callback. */
|
|
126
|
+
async function _startCallbackServerAndWait(mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS) {
|
|
127
|
+
const http = await import('node:http');
|
|
128
|
+
const timeoutHandle = { current: undefined };
|
|
129
|
+
const authResultRef = { value: undefined };
|
|
130
|
+
const authorizationCode = await new Promise((resolve, reject) => {
|
|
131
|
+
const tempServer = http.createServer();
|
|
132
|
+
tempServer.on('request', _makeCallbackHandler(timeoutHandle, tempServer, resolve, reject));
|
|
133
|
+
tempServer.listen(0, _onServerListen(tempServer, mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS, timeoutHandle, authResultRef, reject));
|
|
134
|
+
tempServer.on('error', (err) => {
|
|
135
|
+
if (timeoutHandle.current)
|
|
136
|
+
clearTimeout(timeoutHandle.current);
|
|
137
|
+
reject(err);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
if (timeoutHandle.current)
|
|
141
|
+
clearTimeout(timeoutHandle.current);
|
|
142
|
+
return { authorizationCode, authResult: authResultRef.value };
|
|
143
|
+
}
|
|
144
|
+
/** Listen callback: sets port, starts auth, opens browser, arms timeout. */
|
|
145
|
+
function _onServerListen(tempServer, mgr, metadata, targetServer, startAuthorization, openBrowser, TIMEOUT_MS, timeoutHandle, authResultRef, reject) {
|
|
146
|
+
return async () => {
|
|
147
|
+
try {
|
|
148
|
+
const address = tempServer.address();
|
|
149
|
+
if (!address || typeof address === 'string') {
|
|
150
|
+
reject(new Error('Failed to get server address'));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const actualPort = address.port;
|
|
154
|
+
mgr.setActualPort(actualPort);
|
|
155
|
+
authResultRef.value = await startAuthorization(new URL(metadata.authorization_endpoint), {
|
|
156
|
+
metadata,
|
|
157
|
+
clientInformation: (await mgr.clientInformation()),
|
|
158
|
+
redirectUrl: mgr.redirectUrl,
|
|
159
|
+
scope: targetServer.oauth?.scope,
|
|
160
|
+
});
|
|
161
|
+
await mgr.saveCodeVerifier(authResultRef.value.codeVerifier);
|
|
162
|
+
void openBrowser(authResultRef.value.authorizationUrl.toString());
|
|
163
|
+
timeoutHandle.current = setTimeout(() => {
|
|
164
|
+
tempServer.close();
|
|
165
|
+
reject(new Error(`OAuth login timed out after ${TIMEOUT_MS / 1000} seconds. Please try again.`));
|
|
166
|
+
}, TIMEOUT_MS);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Reads the OAuth login timeout from the
|
|
175
|
+
* `MCP_COMPRESS_ROUTER_LOGIN_TIMEOUT_MS` env var, defaulting to 120 seconds.
|
|
176
|
+
*
|
|
177
|
+
* @returns Timeout in milliseconds.
|
|
178
|
+
*/
|
|
179
|
+
function _readTimeoutMs() {
|
|
180
|
+
const env = process.env.MCP_COMPRESS_ROUTER_LOGIN_TIMEOUT_MS;
|
|
181
|
+
if (env) {
|
|
182
|
+
const parsed = parseInt(env, 10);
|
|
183
|
+
if (!isNaN(parsed) && parsed > 0)
|
|
184
|
+
return parsed;
|
|
185
|
+
}
|
|
186
|
+
return 120_000;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Handles the `login <name>` subcommand.
|
|
190
|
+
*
|
|
191
|
+
* Validates the server exists in config and is an HTTP type.
|
|
192
|
+
* For HTTP servers, runs the OAuth authorization-code flow
|
|
193
|
+
* using the SDK's OAuth client infrastructure. The flow opens
|
|
194
|
+
* a browser, handles the redirect callback, exchanges the
|
|
195
|
+
* authorization code for tokens, and persists them in credentials.json.
|
|
196
|
+
*
|
|
197
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
198
|
+
* @param name - Server name to authenticate.
|
|
199
|
+
* @returns Human-readable confirmation message.
|
|
200
|
+
* @throws If the server name is not found or is not an HTTP type.
|
|
201
|
+
*/
|
|
202
|
+
export async function handleLogin(configPath, name) {
|
|
203
|
+
const { targetServer } = await validateServerForLogin(configPath, name);
|
|
204
|
+
const { OAuthCredentialManager } = await import('../services/oauth.js');
|
|
205
|
+
const mgr = new OAuthCredentialManager(configPath, targetServer);
|
|
206
|
+
const metadata = await setupOAuthClient(mgr, targetServer, name);
|
|
207
|
+
const { authorizationCode, authResult } = await acquireAuthorizationCode(mgr, metadata, targetServer);
|
|
208
|
+
const { exchangeAuthorization } = await _getSdkAuth();
|
|
209
|
+
const realRedirectUrl = mgr.redirectUrl;
|
|
210
|
+
const tokens = await exchangeAuthorization(new URL(metadata.token_endpoint), {
|
|
211
|
+
metadata,
|
|
212
|
+
clientInformation: (await mgr.clientInformation()),
|
|
213
|
+
authorizationCode,
|
|
214
|
+
codeVerifier: authResult.codeVerifier,
|
|
215
|
+
redirectUri: realRedirectUrl,
|
|
216
|
+
});
|
|
217
|
+
await mgr.saveTokens(tokens);
|
|
218
|
+
return `Successfully authenticated server "${name}". Tokens stored in credentials.json.`;
|
|
219
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ensureConfigDir, readConfigFile, readCredentials, removeCredentials, } from './config-io.js';
|
|
2
|
+
/**
|
|
3
|
+
* Handles the `logout <name>` subcommand.
|
|
4
|
+
*
|
|
5
|
+
* Attempts to revoke tokens with the authorization server (best-effort),
|
|
6
|
+
* then removes stored credentials from credentials.json.
|
|
7
|
+
*
|
|
8
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
9
|
+
* @param name - Server name to log out from.
|
|
10
|
+
* @returns Human-readable confirmation message.
|
|
11
|
+
* @throws If the server name is not found.
|
|
12
|
+
*/
|
|
13
|
+
export async function handleLogout(configPath, name) {
|
|
14
|
+
await ensureConfigDir(configPath);
|
|
15
|
+
const servers = await readConfigFile(configPath);
|
|
16
|
+
if (!(name in servers)) {
|
|
17
|
+
const available = Object.keys(servers);
|
|
18
|
+
const hint = available.length > 0
|
|
19
|
+
? ` Available servers: ${available.join(', ')}`
|
|
20
|
+
: ' No servers configured.';
|
|
21
|
+
throw new Error(`Server "${name}" not found.${hint}`);
|
|
22
|
+
}
|
|
23
|
+
const creds = await readCredentials(configPath);
|
|
24
|
+
const serverCreds = creds[name];
|
|
25
|
+
if (!serverCreds) {
|
|
26
|
+
return `No credentials stored for server "${name}".`;
|
|
27
|
+
}
|
|
28
|
+
// Best-effort revocation: if the server's metadata includes a
|
|
29
|
+
// revocation_endpoint, attempt to revoke the tokens.
|
|
30
|
+
// For now, remove credentials directly. The SDK's auth infrastructure
|
|
31
|
+
// handles revocation when available.
|
|
32
|
+
await removeCredentials(configPath, name);
|
|
33
|
+
return `Removed credentials for server "${name}".`;
|
|
34
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ensureConfigDir, readConfigFile, writeConfigFile, removeCredentials, } from './config-io.js';
|
|
2
|
+
/**
|
|
3
|
+
* Handles the `remove <name>` subcommand: deletes a server entry
|
|
4
|
+
* and cleans up any stored OAuth credentials for that server.
|
|
5
|
+
*
|
|
6
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
7
|
+
* @param name - Server name to remove.
|
|
8
|
+
* @returns Human-readable confirmation message to print to stdout.
|
|
9
|
+
* @throws If the server name is not found.
|
|
10
|
+
*/
|
|
11
|
+
export async function handleRemove(configPath, name) {
|
|
12
|
+
await ensureConfigDir(configPath);
|
|
13
|
+
const servers = await readConfigFile(configPath);
|
|
14
|
+
if (!(name in servers)) {
|
|
15
|
+
const available = Object.keys(servers);
|
|
16
|
+
const hint = available.length > 0
|
|
17
|
+
? ` Available servers: ${available.join(', ')}`
|
|
18
|
+
: ' No servers configured.';
|
|
19
|
+
throw new Error(`Server "${name}" not found.${hint}`);
|
|
20
|
+
}
|
|
21
|
+
delete servers[name];
|
|
22
|
+
await writeConfigFile(configPath, servers);
|
|
23
|
+
// Clean up any stored OAuth credentials for the removed server.
|
|
24
|
+
await removeCredentials(configPath, name);
|
|
25
|
+
return `Removed server "${name}".`;
|
|
26
|
+
}
|
package/build/config.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
/**
|
|
5
|
+
* Resolves the configuration file path.
|
|
6
|
+
*
|
|
7
|
+
* Priority: (1) explicit path argument, (2) MCP_COMPRESS_ROUTER_HOME env var,
|
|
8
|
+
* (3) default ~/.local/share/mcp-compress-router/mcp.json.
|
|
9
|
+
*
|
|
10
|
+
* @param explicitPath - An explicit config path, or undefined.
|
|
11
|
+
* @returns The resolved absolute path to the config file.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveConfigPath(explicitPath) {
|
|
14
|
+
if (explicitPath !== undefined) {
|
|
15
|
+
return explicitPath;
|
|
16
|
+
}
|
|
17
|
+
const home = process.env.MCP_COMPRESS_ROUTER_HOME ??
|
|
18
|
+
path.join(os.homedir(), '.local', 'share', 'mcp-compress-router');
|
|
19
|
+
return path.join(home, 'mcp.json');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Loads and validates the MCP configuration file.
|
|
23
|
+
*
|
|
24
|
+
* Reads a JSON file with a top-level mcpServers object, extracts stdio
|
|
25
|
+
* entries, and validates uniqueness. HTTP servers and variable expansion
|
|
26
|
+
* are deferred to later slices.
|
|
27
|
+
*
|
|
28
|
+
* @param configPath - Absolute path to the mcp.json file.
|
|
29
|
+
* @returns An array of validated DownstreamServerConfig objects.
|
|
30
|
+
* @throws If the file is missing, invalid JSON, or contains duplicate names.
|
|
31
|
+
*/
|
|
32
|
+
export async function loadConfig(configPath) {
|
|
33
|
+
const raw = await fs.readFile(configPath, 'utf-8');
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(raw);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error(`Failed to parse config file: ${configPath}`);
|
|
40
|
+
}
|
|
41
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
42
|
+
throw new Error('Config file must contain a JSON object with mcpServers');
|
|
43
|
+
}
|
|
44
|
+
const obj = parsed;
|
|
45
|
+
const mcpServers = obj.mcpServers;
|
|
46
|
+
if (typeof mcpServers !== 'object' || mcpServers === null) {
|
|
47
|
+
throw new Error('Config file must contain an mcpServers object');
|
|
48
|
+
}
|
|
49
|
+
const names = new Set();
|
|
50
|
+
const servers = [];
|
|
51
|
+
for (const [name, entry] of Object.entries(mcpServers)) {
|
|
52
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
53
|
+
throw new Error(`Server "${name}" must be an object`);
|
|
54
|
+
}
|
|
55
|
+
const server = entry;
|
|
56
|
+
if (server.type !== 'stdio') {
|
|
57
|
+
throw new Error(`Server "${name}" has unsupported type "${String(server.type)}"; only "stdio" is supported`);
|
|
58
|
+
}
|
|
59
|
+
if (typeof server.command !== 'string' || server.command.length === 0) {
|
|
60
|
+
throw new Error(`Server "${name}" is missing required "command" field`);
|
|
61
|
+
}
|
|
62
|
+
if (names.has(name)) {
|
|
63
|
+
throw new Error(`Duplicate server name: "${name}"`);
|
|
64
|
+
}
|
|
65
|
+
names.add(name);
|
|
66
|
+
const args = Array.isArray(server.args) ? server.args.map(String) : undefined;
|
|
67
|
+
const env = server.env !== undefined && typeof server.env === 'object'
|
|
68
|
+
? Object.fromEntries(Object.entries(server.env).map(([k, v]) => [k, String(v)]))
|
|
69
|
+
: undefined;
|
|
70
|
+
const description = typeof server.description === 'string' ? server.description : undefined;
|
|
71
|
+
servers.push({
|
|
72
|
+
name,
|
|
73
|
+
command: server.command,
|
|
74
|
+
args,
|
|
75
|
+
env,
|
|
76
|
+
description,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return servers;
|
|
80
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
3
|
+
/**
|
|
4
|
+
* Connects to all configured stdio servers in parallel and discovers
|
|
5
|
+
* their tools. Fails fast if any server is unreachable.
|
|
6
|
+
*
|
|
7
|
+
* @param servers - Validated downstream server configs.
|
|
8
|
+
* @returns Discovered server data for each server.
|
|
9
|
+
* @throws If any server cannot be connected or tools cannot be listed.
|
|
10
|
+
*/
|
|
11
|
+
export async function connectAndDiscover(servers) {
|
|
12
|
+
const results = await Promise.all(servers.map(async (server) => {
|
|
13
|
+
const client = new Client({ name: 'mcp-compress-router', version: '1.0.0' }, { capabilities: {} });
|
|
14
|
+
const transport = new StdioClientTransport({
|
|
15
|
+
command: server.command,
|
|
16
|
+
args: server.args,
|
|
17
|
+
env: server.env,
|
|
18
|
+
});
|
|
19
|
+
try {
|
|
20
|
+
await client.connect(transport);
|
|
21
|
+
const listResult = await client.listTools();
|
|
22
|
+
const tools = listResult.tools.map((t) => ({
|
|
23
|
+
name: t.name,
|
|
24
|
+
description: t.description,
|
|
25
|
+
inputSchema: t.inputSchema,
|
|
26
|
+
}));
|
|
27
|
+
return {
|
|
28
|
+
name: server.name,
|
|
29
|
+
description: server.description,
|
|
30
|
+
tools,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
35
|
+
throw new Error(`Failed to connect to server "${server.name}": ${message}`);
|
|
36
|
+
}
|
|
37
|
+
}));
|
|
38
|
+
return results;
|
|
39
|
+
}
|
package/build/index.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/// <reference types="node" />
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import dotenv from 'dotenv';
|
|
5
|
+
import { Command } from 'commander';
|
|
6
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
+
import { resolveConfigDir, resolveConfigPath, loadConfig, connectAndDiscover, buildCatalog, invokeDownstreamTool, OAuthCredentialManager, persistAuthRequirements, } from './services/index.js';
|
|
9
|
+
import { createGetToolSchemaHandler, buildGetToolSchemaDescription, GetToolSchemaInputSchema, createInvokeToolHandler, InvokeToolInputSchema, } from './tools/index.js';
|
|
10
|
+
import { Logger } from './utils/index.js';
|
|
11
|
+
import { handleAdd, handleRemove, handleGet, handleList, handleLogin, handleLogout, } from './cli/index.js';
|
|
12
|
+
/**
|
|
13
|
+
* Collects repeated `--header "K: V"` flags into a headers record.
|
|
14
|
+
* @internal — Exported for tests only; not part of the public module API.
|
|
15
|
+
*/
|
|
16
|
+
export function collectHeaders(value, previous) {
|
|
17
|
+
const colonIdx = value.indexOf(':');
|
|
18
|
+
if (colonIdx === -1) {
|
|
19
|
+
throw new Error(`Invalid header format: "${value}". Expected "Key: Value".`);
|
|
20
|
+
}
|
|
21
|
+
const key = value.slice(0, colonIdx).trim();
|
|
22
|
+
const val = value.slice(colonIdx + 1).trim();
|
|
23
|
+
return { ...previous, [key]: val };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Collects repeated `-e KEY=value` flags into an env record.
|
|
27
|
+
* @internal — Exported for tests only; not part of the public module API.
|
|
28
|
+
*/
|
|
29
|
+
export function collectEnv(value, previous) {
|
|
30
|
+
const eqIdx = value.indexOf('=');
|
|
31
|
+
if (eqIdx === -1) {
|
|
32
|
+
throw new Error(`Invalid env format: "${value}". Expected "KEY=value".`);
|
|
33
|
+
}
|
|
34
|
+
const key = value.slice(0, eqIdx);
|
|
35
|
+
const val = value.slice(eqIdx + 1);
|
|
36
|
+
return { ...previous, [key]: val };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Wraps a CLI action handler with error handling.
|
|
40
|
+
* On success, writes the result to stdout. On error, writes to stderr
|
|
41
|
+
* and exits with code 1.
|
|
42
|
+
*/
|
|
43
|
+
function guardedAction(fn) {
|
|
44
|
+
return async (...args) => {
|
|
45
|
+
try {
|
|
46
|
+
const result = await fn(...args);
|
|
47
|
+
if (result) {
|
|
48
|
+
process.stdout.write(result + '\n');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
53
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Creates OAuth credential managers for HTTP servers that have stored
|
|
60
|
+
* credentials or oauth overrides, so the transport can handle auth.
|
|
61
|
+
*/
|
|
62
|
+
async function buildAuthProviders(resolved, servers) {
|
|
63
|
+
const authProviders = new Map();
|
|
64
|
+
for (const server of servers) {
|
|
65
|
+
if (server.type === 'http' || server.type === 'streamable-http') {
|
|
66
|
+
const mgr = new OAuthCredentialManager(resolved, server);
|
|
67
|
+
const hasCredentials = (await mgr.tokens()) !== undefined;
|
|
68
|
+
const hasOverrides = server.oauth?.clientId !== undefined;
|
|
69
|
+
if (hasCredentials || hasOverrides) {
|
|
70
|
+
authProviders.set(server.name, mgr);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return authProviders;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Creates the MCP server, registers router tools, and starts the
|
|
78
|
+
* stdio transport.
|
|
79
|
+
*/
|
|
80
|
+
async function startRouterServer(catalog, clients, logger) {
|
|
81
|
+
const router = new McpServer({
|
|
82
|
+
name: 'mcp-compress-router',
|
|
83
|
+
version: '1.0.0',
|
|
84
|
+
});
|
|
85
|
+
router.registerTool('get_tool_schema', {
|
|
86
|
+
title: 'Get Tool Schema',
|
|
87
|
+
description: buildGetToolSchemaDescription(catalog),
|
|
88
|
+
inputSchema: GetToolSchemaInputSchema,
|
|
89
|
+
}, createGetToolSchemaHandler(catalog, logger));
|
|
90
|
+
const invokeFn = (server, tool, args) => invokeDownstreamTool(clients, server, tool, args, logger);
|
|
91
|
+
router.registerTool('invoke_tool', {
|
|
92
|
+
title: 'Invoke Tool',
|
|
93
|
+
description: 'Invoke a specific tool on a connected MCP server. First use get_tool_schema to retrieve the required parameters.',
|
|
94
|
+
inputSchema: InvokeToolInputSchema,
|
|
95
|
+
}, createInvokeToolHandler(catalog, invokeFn, logger));
|
|
96
|
+
const transport = new StdioServerTransport();
|
|
97
|
+
await router.connect(transport);
|
|
98
|
+
logger.info('Server started on stdio');
|
|
99
|
+
}
|
|
100
|
+
async function runRouter(configPath, verbose) {
|
|
101
|
+
const logger = new Logger(verbose ? 'debug' : 'info');
|
|
102
|
+
logger.info('Starting mcp-compress-router', {
|
|
103
|
+
verbose,
|
|
104
|
+
config: configPath ?? '(default)',
|
|
105
|
+
});
|
|
106
|
+
const resolved = await resolveConfigPath(configPath);
|
|
107
|
+
logger.info('Loading configuration', { path: resolved });
|
|
108
|
+
const servers = await loadConfig(resolved);
|
|
109
|
+
logger.info('Configuration loaded', { serverCount: servers.length });
|
|
110
|
+
await persistAuthRequirements(resolved, servers, logger);
|
|
111
|
+
const authProviders = await buildAuthProviders(resolved, servers);
|
|
112
|
+
const getAuthProvider = (server) => authProviders.get(server.name);
|
|
113
|
+
logger.info('Connecting to downstream servers', {
|
|
114
|
+
servers: servers.map((s) => s.name),
|
|
115
|
+
});
|
|
116
|
+
const { servers: discovered, clients } = await connectAndDiscover(servers, logger, getAuthProvider);
|
|
117
|
+
logger.info('Tools discovered', {
|
|
118
|
+
servers: discovered.map((d) => ({
|
|
119
|
+
name: d.name,
|
|
120
|
+
toolCount: d.tools.length,
|
|
121
|
+
tools: d.tools.map((t) => t.name),
|
|
122
|
+
})),
|
|
123
|
+
});
|
|
124
|
+
const catalog = buildCatalog(discovered);
|
|
125
|
+
await startRouterServer(catalog, clients, logger);
|
|
126
|
+
}
|
|
127
|
+
function registerAddCommand(program) {
|
|
128
|
+
program
|
|
129
|
+
.command('add <name> <commandOrUrl> [rest...]')
|
|
130
|
+
.description('Add a downstream MCP server to the configuration')
|
|
131
|
+
.option('-c, --config <path>', 'path to mcp.json configuration file')
|
|
132
|
+
.option('--transport <type>', 'transport type (stdio or http)', 'stdio')
|
|
133
|
+
.option('--header <header>', 'HTTP header (Key: Value)', collectHeaders, {})
|
|
134
|
+
.option('-e, --env <env>', 'environment variable (KEY=value)', collectEnv, {})
|
|
135
|
+
.action(guardedAction(async (name, commandOrUrl, rest, options) => {
|
|
136
|
+
const configPath = await resolveConfigPath(options.config);
|
|
137
|
+
const result = await handleAdd(configPath, {
|
|
138
|
+
name,
|
|
139
|
+
transport: options.transport,
|
|
140
|
+
commandOrUrl,
|
|
141
|
+
rest,
|
|
142
|
+
env: Object.keys(options.env).length > 0 ? options.env : undefined,
|
|
143
|
+
headers: Object.keys(options.header).length > 0 ? options.header : undefined,
|
|
144
|
+
});
|
|
145
|
+
return result;
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
function registerRemoveCommand(program) {
|
|
149
|
+
program
|
|
150
|
+
.command('remove <name>')
|
|
151
|
+
.description('Remove a downstream MCP server from the configuration')
|
|
152
|
+
.option('-c, --config <path>', 'path to mcp.json configuration file')
|
|
153
|
+
.action(guardedAction(async (name, options) => {
|
|
154
|
+
const configPath = await resolveConfigPath(options.config);
|
|
155
|
+
return handleRemove(configPath, name);
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
function registerGetCommand(program) {
|
|
159
|
+
program
|
|
160
|
+
.command('get <name>')
|
|
161
|
+
.description('Show details for a configured downstream MCP server')
|
|
162
|
+
.option('-c, --config <path>', 'path to mcp.json configuration file')
|
|
163
|
+
.action(guardedAction(async (name, options) => {
|
|
164
|
+
const configPath = await resolveConfigPath(options.config);
|
|
165
|
+
return handleGet(configPath, name);
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
function registerListCommand(program) {
|
|
169
|
+
program
|
|
170
|
+
.command('list')
|
|
171
|
+
.description('List all configured downstream MCP servers')
|
|
172
|
+
.option('-c, --config <path>', 'path to mcp.json configuration file')
|
|
173
|
+
.action(guardedAction(async (options) => {
|
|
174
|
+
const configPath = await resolveConfigPath(options.config);
|
|
175
|
+
return handleList(configPath);
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
function registerLoginCommand(program) {
|
|
179
|
+
program
|
|
180
|
+
.command('login <name>')
|
|
181
|
+
.description('Authenticate a downstream server using OAuth')
|
|
182
|
+
.option('-c, --config <path>', 'path to mcp.json configuration file')
|
|
183
|
+
.action(guardedAction(async (name, options) => {
|
|
184
|
+
const configPath = await resolveConfigPath(options.config);
|
|
185
|
+
return handleLogin(configPath, name);
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
function registerLogoutCommand(program) {
|
|
189
|
+
program
|
|
190
|
+
.command('logout <name>')
|
|
191
|
+
.description('Revoke and remove OAuth credentials for a downstream server')
|
|
192
|
+
.option('-c, --config <path>', 'path to mcp.json configuration file')
|
|
193
|
+
.action(guardedAction(async (name, options) => {
|
|
194
|
+
const configPath = await resolveConfigPath(options.config);
|
|
195
|
+
return handleLogout(configPath, name);
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
function registerRouterCommand(program) {
|
|
199
|
+
program
|
|
200
|
+
.option('-c, --config <path>', 'path to mcp.json configuration file')
|
|
201
|
+
.option('-v, --verbose', 'enable debug-level logging to stderr')
|
|
202
|
+
.action(async (options) => {
|
|
203
|
+
const verbose = options.verbose || process.env.MCP_COMPRESS_ROUTER_VERBOSE === 'true';
|
|
204
|
+
await runRouter(options.config, verbose);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
async function main() {
|
|
208
|
+
// Load .env from the config directory before any config resolution
|
|
209
|
+
// or env var expansion. `quiet` suppresses dotenv's startup log line.
|
|
210
|
+
dotenv.config({
|
|
211
|
+
path: path.join(resolveConfigDir(), '.env'),
|
|
212
|
+
quiet: true,
|
|
213
|
+
});
|
|
214
|
+
const program = new Command();
|
|
215
|
+
program
|
|
216
|
+
.name('mcp-compress-router')
|
|
217
|
+
.description('Compress all connected MCP servers into a single router MCP');
|
|
218
|
+
registerAddCommand(program);
|
|
219
|
+
registerRemoveCommand(program);
|
|
220
|
+
registerGetCommand(program);
|
|
221
|
+
registerListCommand(program);
|
|
222
|
+
registerLoginCommand(program);
|
|
223
|
+
registerLogoutCommand(program);
|
|
224
|
+
registerRouterCommand(program);
|
|
225
|
+
await program.parseAsync();
|
|
226
|
+
}
|
|
227
|
+
main().catch((err) => {
|
|
228
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
229
|
+
// Use a fresh error-level logger for fatal startup errors.
|
|
230
|
+
const fatalLogger = new Logger('debug');
|
|
231
|
+
if (message.includes('Failed to connect to server')) {
|
|
232
|
+
fatalLogger.error('Cannot build complete tool catalog — downstream connection failed', {
|
|
233
|
+
error: message,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
fatalLogger.error('Fatal startup error', { error: message });
|
|
238
|
+
}
|
|
239
|
+
process.exitCode = 1;
|
|
240
|
+
process.exit(1);
|
|
241
|
+
});
|