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,145 @@
1
+ import { readCredentials, writeCredentials, removeCredentials } from '../cli/config-io.js';
2
+ /**
3
+ * Implements OAuthClientProvider backed by credentials.json credential storage.
4
+ *
5
+ * Each instance manages credentials for one downstream server.
6
+ * When `oauth` overrides are present in the server config, dynamic
7
+ * client registration is skipped and static client info is used.
8
+ */
9
+ export class OAuthCredentialManager {
10
+ _configPath;
11
+ _server;
12
+ _codeVerifier;
13
+ _staticClientInfo;
14
+ _actualPort = 0;
15
+ constructor(configPath, server) {
16
+ this._configPath = configPath;
17
+ this._server = server;
18
+ // If oauth overrides are present, set up static client info
19
+ if (server.oauth?.clientId) {
20
+ const info = {
21
+ client_id: server.oauth.clientId,
22
+ };
23
+ if (server.oauth.clientSecret) {
24
+ info.client_secret = server.oauth.clientSecret;
25
+ }
26
+ this._staticClientInfo = info;
27
+ }
28
+ }
29
+ get redirectUrl() {
30
+ // Return the callback URL with the actual port assigned by the OS.
31
+ // Falls back to port 0 until setActualPort() is called by login-command.
32
+ return `http://localhost:${this._actualPort}/callback`;
33
+ }
34
+ get clientMetadata() {
35
+ return {
36
+ redirect_uris: [this.redirectUrl],
37
+ client_name: 'mcp-compress-router',
38
+ };
39
+ }
40
+ /**
41
+ * Sets the actual listening port of the temporary HTTP callback server.
42
+ * Must be called before startAuthorization so the redirect_uri is correct.
43
+ *
44
+ * @param port - The actual port the callback server is listening on.
45
+ */
46
+ setActualPort(port) {
47
+ this._actualPort = port;
48
+ }
49
+ /**
50
+ * Whether this manager has static (override) client information,
51
+ * bypassing dynamic client registration.
52
+ */
53
+ hasStaticClient() {
54
+ return this._staticClientInfo !== undefined;
55
+ }
56
+ async clientInformation() {
57
+ // Static overrides take precedence
58
+ if (this._staticClientInfo) {
59
+ return this._staticClientInfo;
60
+ }
61
+ const creds = await this._loadCredentials();
62
+ return creds?.clientRegistration;
63
+ }
64
+ async saveClientInformation(clientInformation) {
65
+ if (this._staticClientInfo) {
66
+ // Don't overwrite static overrides
67
+ return;
68
+ }
69
+ const creds = await this._loadCredentials();
70
+ const updated = {
71
+ clientRegistration: clientInformation,
72
+ // Preserve existing tokens; omit when none (tokens is optional).
73
+ ...(creds?.tokens ? { tokens: creds.tokens } : {}),
74
+ // Successful OAuth client registration proves the server supports
75
+ // OAuth. Override any stale cached requirement.
76
+ authRequirement: 'oauth',
77
+ checkedAt: new Date().toISOString(),
78
+ };
79
+ await writeCredentials(this._configPath, this._server.name, updated);
80
+ }
81
+ async tokens() {
82
+ const creds = await this._loadCredentials();
83
+ if (!creds?.tokens?.access_token) {
84
+ return undefined;
85
+ }
86
+ return creds.tokens;
87
+ }
88
+ async saveTokens(tokens) {
89
+ const creds = await this._loadCredentials();
90
+ const updated = {
91
+ clientRegistration: creds?.clientRegistration,
92
+ tokens: {
93
+ access_token: tokens.access_token,
94
+ refresh_token: tokens.refresh_token,
95
+ expires_in: tokens.expires_in,
96
+ scope: tokens.scope,
97
+ token_type: tokens.token_type,
98
+ },
99
+ // A successful OAuth token exchange proves the server supports
100
+ // OAuth. Override any stale cached requirement (e.g. 'none' from
101
+ // a failed startup probe) with 'oauth'.
102
+ authRequirement: 'oauth',
103
+ checkedAt: new Date().toISOString(),
104
+ };
105
+ await writeCredentials(this._configPath, this._server.name, updated);
106
+ }
107
+ async redirectToAuthorization(authorizationUrl) {
108
+ // Open the authorization URL in the default browser.
109
+ const { openBrowser } = await import('../utils/open-browser.js');
110
+ await openBrowser(authorizationUrl.toString());
111
+ }
112
+ async saveCodeVerifier(codeVerifier) {
113
+ this._codeVerifier = codeVerifier;
114
+ }
115
+ async codeVerifier() {
116
+ if (!this._codeVerifier) {
117
+ throw new Error('No code verifier saved');
118
+ }
119
+ return this._codeVerifier;
120
+ }
121
+ /**
122
+ * Removes stored OAuth tokens and client registration for this server
123
+ * (used by logout). Preserves the cached auth requirement so the
124
+ * `list` command still shows the correct status (e.g. "requires
125
+ * login") after logout. When there is no cached auth requirement to
126
+ * keep, the entire entry is removed (and the credentials file deleted
127
+ * when it becomes empty).
128
+ */
129
+ async clearTokens() {
130
+ this._codeVerifier = undefined;
131
+ const creds = await this._loadCredentials();
132
+ if (creds?.authRequirement) {
133
+ await writeCredentials(this._configPath, this._server.name, {
134
+ authRequirement: creds.authRequirement,
135
+ checkedAt: creds.checkedAt,
136
+ });
137
+ return;
138
+ }
139
+ await removeCredentials(this._configPath, this._server.name);
140
+ }
141
+ async _loadCredentials() {
142
+ const all = await readCredentials(this._configPath);
143
+ return all[this._server.name];
144
+ }
145
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Renders the compact catalog as Markdown text suitable for inclusion
3
+ * in the `get_tool_schema` tool description.
4
+ *
5
+ * Format:
6
+ * ## {server name}
7
+ * {description (optional)}
8
+ * {tool1}, {tool2}, ...
9
+ *
10
+ * @param servers - The catalog server entries.
11
+ * @returns Compact catalog text.
12
+ */
13
+ export function renderCompactCatalog(servers) {
14
+ const lines = [];
15
+ for (const server of servers) {
16
+ lines.push(`## ${server.name}`);
17
+ if (server.description) {
18
+ lines.push(server.description);
19
+ }
20
+ lines.push(server.tools.map((t) => t.name).join(', '));
21
+ lines.push(''); // blank line between servers
22
+ }
23
+ return lines.join('\n').trimEnd();
24
+ }
@@ -0,0 +1,61 @@
1
+ import { lookupTools } from '../services/index.js';
2
+ import { renderCompactCatalog } from '../utils/index.js';
3
+ import { z } from 'zod';
4
+ /**
5
+ * Schema for get_tool_schema parameters.
6
+ */
7
+ export const GetToolSchemaInputSchema = {
8
+ server: z.string().describe('The name of the MCP server to query.'),
9
+ tools: z.array(z.string()).min(1).max(50).describe('List of tool names to get the schema for.'),
10
+ };
11
+ /**
12
+ * Creates the get_tool_schema handler closure over the catalog.
13
+ *
14
+ * @param catalog - The immutable tool catalog built at startup.
15
+ * @param logger - Structured logger for diagnostic output.
16
+ * @returns A handler function for the get_tool_schema MCP tool.
17
+ */
18
+ export function createGetToolSchemaHandler(catalog, logger) {
19
+ return async (params) => {
20
+ logger.info('get_tool_schema called', {
21
+ server: params.server,
22
+ tools: params.tools,
23
+ });
24
+ try {
25
+ const schemas = lookupTools(catalog, params.server, params.tools);
26
+ const result = schemas.map((t) => ({
27
+ name: t.name,
28
+ description: t.description ?? '',
29
+ inputSchema: t.inputSchema,
30
+ }));
31
+ logger.debug('get_tool_schema result', { result });
32
+ return {
33
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
34
+ };
35
+ }
36
+ catch (err) {
37
+ const message = err instanceof Error ? err.message : String(err);
38
+ logger.error('get_tool_schema failed', {
39
+ server: params.server,
40
+ tools: params.tools,
41
+ error: message,
42
+ });
43
+ return {
44
+ content: [{ type: 'text', text: `Error: ${message}` }],
45
+ isError: true,
46
+ };
47
+ }
48
+ };
49
+ }
50
+ /**
51
+ * Builds the description string for get_tool_schema from the catalog.
52
+ *
53
+ * @param catalog - The tool catalog.
54
+ * @returns A description string containing the compact catalog.
55
+ */
56
+ export function buildGetToolSchemaDescription(catalog) {
57
+ const compact = renderCompactCatalog(catalog.servers);
58
+ return ('Get the JSON schema for one or more tools from a connected MCP server.\n\n' +
59
+ 'Available MCP servers and their tools:\n\n' +
60
+ compact);
61
+ }
@@ -0,0 +1,2 @@
1
+ export { createGetToolSchemaHandler, buildGetToolSchemaDescription, GetToolSchemaInputSchema, } from './get-tool-schema.js';
2
+ export { createInvokeToolHandler, InvokeToolInputSchema } from './invoke-tool.js';
@@ -0,0 +1,92 @@
1
+ import { z } from 'zod';
2
+ import { validateArguments } from '../utils/index.js';
3
+ import { lookupTools } from '../services/index.js';
4
+ /**
5
+ * Schema for invoke_tool parameters.
6
+ */
7
+ export const InvokeToolInputSchema = {
8
+ server: z.string().describe('The name of the MCP server that has the tool.'),
9
+ tool: z.string().describe('The name of the tool to invoke.'),
10
+ arguments: z.object({}).passthrough().describe('The arguments to pass to the tool.'),
11
+ };
12
+ /**
13
+ * Validates invocation arguments against the tool's cached input
14
+ * schema. Returns an error response when validation fails, or null
15
+ * when arguments are valid.
16
+ *
17
+ * @param catalog - The immutable tool catalog.
18
+ * @param server - The downstream server name.
19
+ * @param tool - The tool name.
20
+ * @param args - The arguments to validate.
21
+ * @param logger - Structured logger for diagnostic output.
22
+ * @returns A validation error response, or null when valid.
23
+ */
24
+ function validateInvokeArgs(catalog, server, tool, args, logger) {
25
+ const descriptor = catalog.toolMap.get(`${server}::${tool}`);
26
+ if (descriptor) {
27
+ const validation = validateArguments(args, descriptor.inputSchema);
28
+ if (!validation.valid) {
29
+ logger.error('invoke_tool validation failed', {
30
+ server,
31
+ tool,
32
+ errors: validation.errors,
33
+ });
34
+ return {
35
+ content: [
36
+ {
37
+ type: 'text',
38
+ text: `Invalid arguments:\n${validation.errors.join('\n')}`,
39
+ },
40
+ ],
41
+ isError: true,
42
+ };
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+ /**
48
+ * Creates the invoke_tool handler closure over the catalog and an
49
+ * invoker function. The handler receives only the catalog and a
50
+ * function — never raw transport clients.
51
+ *
52
+ * @param catalog - The immutable tool catalog built at startup.
53
+ * @param invokeDownstream - Function to forward a tool call to the
54
+ * correct downstream server.
55
+ * @param logger - Structured logger for diagnostic output.
56
+ * @returns A handler function for the invoke_tool MCP tool.
57
+ */
58
+ export function createInvokeToolHandler(catalog, invokeDownstream, logger) {
59
+ return async (params) => {
60
+ logger.info('invoke_tool called', {
61
+ server: params.server,
62
+ tool: params.tool,
63
+ });
64
+ logger.debug('invoke_tool arguments', { arguments: params.arguments });
65
+ try {
66
+ // Validate server and tool exist in catalog
67
+ lookupTools(catalog, params.server, [params.tool]);
68
+ // Validate arguments against the cached input schema
69
+ const validationError = validateInvokeArgs(catalog, params.server, params.tool, params.arguments, logger);
70
+ if (validationError)
71
+ return validationError;
72
+ const result = await invokeDownstream(params.server, params.tool, params.arguments);
73
+ // Return content verbatim. The result type is looser than what
74
+ // the MCP SDK expects, so we cast through unknown to satisfy
75
+ // the handler signature while preserving the actual content
76
+ // structure.
77
+ return result;
78
+ }
79
+ catch (err) {
80
+ const message = err instanceof Error ? err.message : String(err);
81
+ logger.error('invoke_tool failed', {
82
+ server: params.server,
83
+ tool: params.tool,
84
+ error: message,
85
+ });
86
+ return {
87
+ content: [{ type: 'text', text: `Error: ${message}` }],
88
+ isError: true,
89
+ };
90
+ }
91
+ };
92
+ }
package/build/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Error thrown when a `${VAR}` reference references an undefined
3
+ * environment variable and has no default value.
4
+ *
5
+ * @public
6
+ */
7
+ export class ExpandEnvError extends Error {
8
+ /** The name of the unresolved variable. */
9
+ variableName;
10
+ /** The field or context where the unresolved variable was found. */
11
+ context;
12
+ constructor(variableName, context) {
13
+ super(`Environment variable "${variableName}" is not set (referenced in ${context})`);
14
+ this.name = 'ExpandEnvError';
15
+ this.variableName = variableName;
16
+ this.context = context;
17
+ }
18
+ }
19
+ // Matches ${VAR} and ${VAR:-default}
20
+ const ENV_REF_RE = /\$\{([A-Za-z_]\w*)(?::-(.*?))?\}/g;
21
+ /**
22
+ * Expands `${VAR}` and `${VAR:-default}` references in a string value.
23
+ *
24
+ * - `${VAR}` is replaced with `process.env[VAR]`. If VAR is not set,
25
+ * throws {@link ExpandEnvError}.
26
+ * - `${VAR:-default}` is replaced with `process.env[VAR]` if set and
27
+ * non-empty, otherwise with `default`.
28
+ *
29
+ * @param value - The string containing potentially unexpanded references.
30
+ * @param context - Human-readable description of where the value comes
31
+ * from (e.g. `server "github" command`), used in error messages.
32
+ * @returns The expanded string.
33
+ * @throws {ExpandEnvError} When a `${VAR}` reference has no default and
34
+ * the variable is not set in the environment.
35
+ */
36
+ export function expandEnvField(value, context) {
37
+ return value.replace(ENV_REF_RE, (match, varName, defaultVal) => {
38
+ const envValue = process.env[varName];
39
+ if (envValue !== undefined && envValue !== '') {
40
+ return envValue;
41
+ }
42
+ if (defaultVal !== undefined) {
43
+ return defaultVal;
44
+ }
45
+ throw new ExpandEnvError(varName, context);
46
+ });
47
+ }
@@ -0,0 +1,7 @@
1
+ export { renderCompactCatalog } from './text-format.js';
2
+ export { validateArguments } from './validate-arguments.js';
3
+ export { expandEnvField } from './expand-env.js';
4
+ export { Logger } from './logger.js';
5
+ export { parseJsonc } from './parse-jsonc.js';
6
+ /** @public */
7
+ export { openBrowser } from './open-browser.js';
@@ -0,0 +1,78 @@
1
+ const LEVEL_ORDER = {
2
+ error: 0,
3
+ info: 1,
4
+ debug: 2,
5
+ };
6
+ /**
7
+ * A lightweight structured logger that writes timestamped JSON lines
8
+ * to stderr with configurable level filtering.
9
+ */
10
+ export class Logger {
11
+ currentLevel;
12
+ /**
13
+ * Creates a Logger at the given level. Messages below this level
14
+ * are silently dropped.
15
+ *
16
+ * @param level - Minimum log level to emit.
17
+ */
18
+ constructor(level = 'info') {
19
+ this.currentLevel = level;
20
+ }
21
+ /**
22
+ * Returns true if messages at the given level would be emitted.
23
+ *
24
+ * @param level - The level to check.
25
+ */
26
+ isLevelEnabled(level) {
27
+ return LEVEL_ORDER[level] <= LEVEL_ORDER[this.currentLevel];
28
+ }
29
+ /**
30
+ * Changes the minimum log level at runtime.
31
+ *
32
+ * @param level - New minimum log level.
33
+ */
34
+ setLevel(level) {
35
+ this.currentLevel = level;
36
+ }
37
+ /**
38
+ * Emits an error-level log entry.
39
+ *
40
+ * @param message - Human-readable error message.
41
+ * @param context - Optional key-value metadata (server name, error details).
42
+ */
43
+ error(message, context) {
44
+ this.log('error', message, context);
45
+ }
46
+ /**
47
+ * Emits an info-level log entry. Suppressed when level is 'error'.
48
+ *
49
+ * @param message - Human-readable info message.
50
+ * @param context - Optional key-value metadata.
51
+ */
52
+ info(message, context) {
53
+ this.log('info', message, context);
54
+ }
55
+ /**
56
+ * Emits a debug-level log entry. Suppressed unless level is 'debug'.
57
+ *
58
+ * @param message - Human-readable debug message.
59
+ * @param context - Optional key-value metadata (payloads, state).
60
+ */
61
+ debug(message, context) {
62
+ this.log('debug', message, context);
63
+ }
64
+ log(level, message, context) {
65
+ if (!this.isLevelEnabled(level)) {
66
+ return;
67
+ }
68
+ const entry = {
69
+ timestamp: new Date().toISOString(),
70
+ level,
71
+ message,
72
+ };
73
+ if (context !== undefined && Object.keys(context).length > 0) {
74
+ entry.context = context;
75
+ }
76
+ process.stderr.write(JSON.stringify(entry) + '\n');
77
+ }
78
+ }
@@ -0,0 +1,49 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Opens a URL in the default browser using the platform-native command.
4
+ *
5
+ * The browser command can be overridden with the
6
+ * `MCP_COMPRESS_ROUTER_BROWSER` environment variable. Set it to the
7
+ * executable plus any preset arguments (e.g.
8
+ * `node /path/to/headless-browser.js --flag`); the URL is always appended
9
+ * as a single, final argument. No shell is used, so there is no
10
+ * shell-injection risk — this also makes the override safe to drive OAuth
11
+ * flows in headless and CI environments.
12
+ *
13
+ * @param url - The URL to open.
14
+ */
15
+ export async function openBrowser(url) {
16
+ const customBrowser = process.env.MCP_COMPRESS_ROUTER_BROWSER;
17
+ if (customBrowser && customBrowser.trim().length > 0) {
18
+ const [command, ...presetArgs] = customBrowser.trim().split(/\s+/);
19
+ return spawnBrowser(command, [...presetArgs, url]);
20
+ }
21
+ const platform = process.platform;
22
+ if (platform === 'darwin') {
23
+ return spawnBrowser('open', [url]);
24
+ }
25
+ if (platform === 'win32') {
26
+ // `start` is a shell built-in, so shell: true is unavoidable on Windows.
27
+ // No user-controlled string is interpolated into the command template.
28
+ return spawnBrowser('start', ['""', url], { shell: true });
29
+ }
30
+ return spawnBrowser('xdg-open', [url]);
31
+ }
32
+ /**
33
+ * Spawns a browser command and resolves once it has spawned.
34
+ *
35
+ * The browser process is fire-and-forget; this only waits for a successful
36
+ * spawn, not for the process to exit.
37
+ *
38
+ * @param command - The executable to run.
39
+ * @param args - Arguments to pass to the executable (including the URL).
40
+ * @param options - Optional spawn options (e.g. `shell: true` on Windows).
41
+ * @throws If the process fails to spawn.
42
+ */
43
+ function spawnBrowser(command, args, options) {
44
+ return new Promise((resolve, reject) => {
45
+ const child = options ? spawn(command, args, options) : spawn(command, args);
46
+ child.on('error', reject);
47
+ child.on('spawn', () => resolve());
48
+ });
49
+ }
@@ -0,0 +1,24 @@
1
+ import { parse, printParseErrorCode } from 'jsonc-parser';
2
+ /**
3
+ * Parses JSON or JSONC (comments + trailing commas) text.
4
+ *
5
+ * The parser is fault-tolerant: trailing commas, extra whitespace, and
6
+ * other minor issues produce warnings but still yield a result. Only
7
+ * unrecoverable syntax errors throw.
8
+ *
9
+ * @param text - The text to parse.
10
+ * @param context - Human-readable description of where the text comes
11
+ * from (e.g. a file path), used in error messages.
12
+ * @returns The parsed value.
13
+ * @throws Error when the text contains unrecoverable syntax errors.
14
+ */
15
+ export function parseJsonc(text, context) {
16
+ const errors = [];
17
+ const result = parse(text, errors);
18
+ // parse() uses error recovery — only the first error that prevents
19
+ // any valid result makes result undefined.
20
+ if (result === undefined && errors.length > 0) {
21
+ throw new Error(`Failed to parse ${context}: ${printParseErrorCode(errors[0].error)}`);
22
+ }
23
+ return result;
24
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Renders the compact catalog as Markdown text suitable for inclusion
3
+ * in the `get_tool_schema` tool description.
4
+ *
5
+ * Format:
6
+ * ## {server name}
7
+ * {description (optional)}
8
+ * {tool1}, {tool2}, ...
9
+ *
10
+ * @param servers - The catalog server entries.
11
+ * @returns Compact catalog text.
12
+ */
13
+ export function renderCompactCatalog(servers) {
14
+ const lines = [];
15
+ for (const server of servers) {
16
+ lines.push(`## ${server.name}`);
17
+ if (server.description) {
18
+ lines.push(server.description);
19
+ }
20
+ lines.push(server.tools.map((t) => t.name).join(', '));
21
+ lines.push(''); // blank line between servers
22
+ }
23
+ return lines.join('\n').trimEnd();
24
+ }
@@ -0,0 +1 @@
1
+ export {};