mcp-compression-proxy 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/LICENSE +21 -0
  3. package/README.md +842 -0
  4. package/dist/cli/commands.d.ts +25 -0
  5. package/dist/cli/commands.js +152 -0
  6. package/dist/cli/daemon.d.ts +4 -0
  7. package/dist/cli/daemon.js +336 -0
  8. package/dist/cli/index.d.ts +3 -0
  9. package/dist/cli/index.js +269 -0
  10. package/dist/cli/ipc-client.d.ts +11 -0
  11. package/dist/cli/ipc-client.js +81 -0
  12. package/dist/cli/payload-interceptor.d.ts +6 -0
  13. package/dist/cli/payload-interceptor.js +49 -0
  14. package/dist/config/loader.d.ts +53 -0
  15. package/dist/config/loader.js +332 -0
  16. package/dist/config/schema.d.ts +164 -0
  17. package/dist/config/schema.js +127 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.js +821 -0
  20. package/dist/mcp/client-manager.d.ts +65 -0
  21. package/dist/mcp/client-manager.js +197 -0
  22. package/dist/services/compression-cache.d.ts +112 -0
  23. package/dist/services/compression-cache.js +238 -0
  24. package/dist/services/compression-persistence.d.ts +36 -0
  25. package/dist/services/compression-persistence.js +111 -0
  26. package/dist/services/compression-sampler.d.ts +89 -0
  27. package/dist/services/compression-sampler.js +171 -0
  28. package/dist/services/session-manager.d.ts +64 -0
  29. package/dist/services/session-manager.js +160 -0
  30. package/dist/services/stats-service.d.ts +101 -0
  31. package/dist/services/stats-service.js +246 -0
  32. package/dist/types/compression.d.ts +38 -0
  33. package/dist/types/compression.js +5 -0
  34. package/dist/types/index.d.ts +108 -0
  35. package/dist/types/index.js +2 -0
  36. package/dist/version.d.ts +11 -0
  37. package/dist/version.js +11 -0
  38. package/package.json +110 -0
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'child_process';
3
+ import { existsSync, readFileSync, unlinkSync } from 'fs';
4
+ import { join, dirname } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { homedir } from 'os';
7
+ import { isDaemonRunning } from './ipc-client.js';
8
+ import { handleTools, handleSearch, handleInfo, handleCall, handleStats, handleDaemonStatus, } from './commands.js';
9
+ const BASE_DIR = join(homedir(), '.mcp-compression-proxy');
10
+ const SOCKET_PATH = join(BASE_DIR, 'daemon.sock');
11
+ const PID_FILE = join(BASE_DIR, 'daemon.pid');
12
+ const READY_FILE = join(BASE_DIR, 'daemon.ready');
13
+ const USAGE = `
14
+ mcp-cli — Progressive MCP tool discovery for LLMs
15
+
16
+ Usage:
17
+ mcp-cli tools List all tools (compressed)
18
+ mcp-cli search <query> Search tools by name/description
19
+ mcp-cli info <server>/<tool> Get full schema for a tool
20
+ mcp-cli call <server>/<tool> <json> Execute a tool
21
+ mcp-cli stats Show compression statistics
22
+ mcp-cli daemon start Start the background daemon
23
+ mcp-cli daemon stop Stop the daemon
24
+ mcp-cli daemon status Show daemon status
25
+ mcp-cli help Show this help
26
+
27
+ Options:
28
+ --no-auto-start Don't auto-start daemon
29
+ `.trim();
30
+ /**
31
+ * Start the daemon as a background process.
32
+ * Forks the daemon.ts module with detached: true.
33
+ */
34
+ async function startDaemon() {
35
+ const __filename = fileURLToPath(import.meta.url);
36
+ const __dirname = dirname(__filename);
37
+ const daemonScript = join(__dirname, 'daemon.js');
38
+ if (!existsSync(daemonScript)) {
39
+ console.error(`Error: Daemon script not found at ${daemonScript}`);
40
+ console.error('Run "npm run build" first.');
41
+ return false;
42
+ }
43
+ // Clean up stale ready file
44
+ if (existsSync(READY_FILE)) {
45
+ unlinkSync(READY_FILE);
46
+ }
47
+ // spawn, not fork: fork opens an IPC channel to the child, and that channel
48
+ // keeps this process's event loop alive even after child.unref(), so
49
+ // `mcp-cli daemon start` printed its success message and then hung forever.
50
+ // The daemon never uses process.send(), so the channel was pure overhead.
51
+ const child = spawn(process.execPath, [daemonScript], {
52
+ detached: true,
53
+ stdio: 'ignore',
54
+ });
55
+ child.unref();
56
+ // Wait for the daemon to be ready (socket available)
57
+ const maxWaitMs = 15000;
58
+ const pollIntervalMs = 200;
59
+ const startTime = Date.now();
60
+ while (Date.now() - startTime < maxWaitMs) {
61
+ if (existsSync(READY_FILE)) {
62
+ return true;
63
+ }
64
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
65
+ }
66
+ // Fallback: check if socket is reachable
67
+ return isDaemonRunning(SOCKET_PATH);
68
+ }
69
+ /** Remove the socket, PID and ready markers left behind by a dead daemon. */
70
+ function cleanupDaemonFiles() {
71
+ for (const file of [PID_FILE, SOCKET_PATH, READY_FILE]) {
72
+ try {
73
+ unlinkSync(file);
74
+ }
75
+ catch {
76
+ /* ignore */
77
+ }
78
+ }
79
+ }
80
+ /**
81
+ * Stop the daemon by sending SIGTERM and waiting for it to actually exit.
82
+ */
83
+ async function stopDaemon() {
84
+ if (!existsSync(PID_FILE)) {
85
+ console.log('Daemon is not running (no PID file).');
86
+ return false;
87
+ }
88
+ const pid = Number.parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
89
+ // A truncated or corrupt PID file yields NaN, and process.kill(NaN) throws
90
+ // ERR_INVALID_ARG_TYPE rather than ESRCH - which would escape the handler
91
+ // below and surface as a stack trace instead of a stale-file message.
92
+ if (!Number.isInteger(pid) || pid <= 0) {
93
+ cleanupDaemonFiles();
94
+ console.log('Daemon is not running (corrupt PID file cleaned up).');
95
+ return false;
96
+ }
97
+ // A PID alone is not proof this is our daemon: if it died without cleaning
98
+ // up, the OS may have recycled the number for an unrelated process, and
99
+ // signalling that would kill something we do not own. Only trust the PID
100
+ // when the daemon also answers on its socket.
101
+ if (!(await isDaemonRunning(SOCKET_PATH))) {
102
+ cleanupDaemonFiles();
103
+ console.log('Daemon was not running (stale PID file cleaned up).');
104
+ return false;
105
+ }
106
+ try {
107
+ process.kill(pid, 'SIGTERM');
108
+ }
109
+ catch (error) {
110
+ if (error.code === 'ESRCH') {
111
+ cleanupDaemonFiles();
112
+ console.log('Daemon was not running (stale PID file cleaned up).');
113
+ return false;
114
+ }
115
+ throw error;
116
+ }
117
+ // Shutdown disconnects every backend server first, so the process does not
118
+ // exit immediately. Reporting success too early lets a follow-up `daemon
119
+ // start` race the old daemon, which still owns the socket.
120
+ const deadline = Date.now() + 10000;
121
+ while (Date.now() < deadline) {
122
+ try {
123
+ process.kill(pid, 0);
124
+ }
125
+ catch {
126
+ cleanupDaemonFiles();
127
+ console.log(`Daemon stopped (PID ${pid}).`);
128
+ return true;
129
+ }
130
+ await new Promise((resolve) => setTimeout(resolve, 100));
131
+ }
132
+ console.error(`Daemon (PID ${pid}) did not exit within 10s; it may still be shutting down.`);
133
+ return false;
134
+ }
135
+ /**
136
+ * Ensure daemon is running, auto-starting if needed.
137
+ */
138
+ async function ensureDaemon(noAutoStart) {
139
+ const running = await isDaemonRunning(SOCKET_PATH);
140
+ if (running)
141
+ return;
142
+ if (noAutoStart) {
143
+ console.error('Error: Daemon is not running. Start it with: mcp-cli daemon start');
144
+ process.exit(1);
145
+ }
146
+ console.error('Daemon not running. Starting...');
147
+ const started = await startDaemon();
148
+ if (!started) {
149
+ console.error('Error: Failed to start daemon. Try manually: mcp-cli daemon start');
150
+ process.exit(1);
151
+ }
152
+ console.error('Daemon started.');
153
+ }
154
+ /**
155
+ * Read JSON payload from stdin if available.
156
+ */
157
+ async function readStdin() {
158
+ if (process.stdin.isTTY)
159
+ return null;
160
+ return new Promise((resolve) => {
161
+ let data = '';
162
+ process.stdin.setEncoding('utf-8');
163
+ // Give up after 1s rather than hanging on a pipe that never closes. The
164
+ // timer is unref'd and cleared so it cannot keep the CLI alive after
165
+ // stdin has already ended.
166
+ const timer = setTimeout(() => resolve(data.trim() || null), 1000);
167
+ timer.unref?.();
168
+ process.stdin.on('data', (chunk) => { data += chunk; });
169
+ process.stdin.on('end', () => {
170
+ clearTimeout(timer);
171
+ resolve(data.trim() || null);
172
+ });
173
+ });
174
+ }
175
+ async function main() {
176
+ const args = process.argv.slice(2);
177
+ const noAutoStart = args.includes('--no-auto-start');
178
+ const filteredArgs = args.filter(a => a !== '--no-auto-start');
179
+ const command = filteredArgs[0];
180
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
181
+ console.log(USAGE);
182
+ return;
183
+ }
184
+ // Daemon management commands don't need a running daemon
185
+ if (command === 'daemon') {
186
+ const action = filteredArgs[1];
187
+ switch (action) {
188
+ case 'start': {
189
+ const running = await isDaemonRunning(SOCKET_PATH);
190
+ if (running) {
191
+ console.log('Daemon is already running.');
192
+ return;
193
+ }
194
+ const started = await startDaemon();
195
+ if (started) {
196
+ // Get connection info
197
+ const response = await (await import('./ipc-client.js')).sendRequest(SOCKET_PATH, 'daemon-status');
198
+ if (!response.error) {
199
+ const status = response.result;
200
+ console.log(`Daemon started (PID ${status.pid}). Connected to ${status.connectedServers}/${status.totalServers} servers.`);
201
+ }
202
+ else {
203
+ console.log('Daemon started.');
204
+ }
205
+ }
206
+ else {
207
+ console.error('Failed to start daemon.');
208
+ process.exit(1);
209
+ }
210
+ return;
211
+ }
212
+ case 'stop':
213
+ await stopDaemon();
214
+ return;
215
+ case 'status':
216
+ await handleDaemonStatus(SOCKET_PATH);
217
+ return;
218
+ default:
219
+ console.error('Usage: mcp-cli daemon <start|stop|status>');
220
+ process.exit(1);
221
+ }
222
+ }
223
+ // All other commands require a running daemon
224
+ await ensureDaemon(noAutoStart);
225
+ switch (command) {
226
+ case 'tools':
227
+ await handleTools(SOCKET_PATH);
228
+ break;
229
+ case 'search':
230
+ await handleSearch(SOCKET_PATH, filteredArgs.slice(1).join(' '));
231
+ break;
232
+ case 'info':
233
+ if (!filteredArgs[1]) {
234
+ console.error('Usage: mcp-cli info <server>/<tool>');
235
+ process.exit(1);
236
+ }
237
+ await handleInfo(SOCKET_PATH, filteredArgs[1]);
238
+ break;
239
+ case 'call': {
240
+ if (!filteredArgs[1]) {
241
+ console.error('Usage: mcp-cli call <server>/<tool> \'<json_payload>\'');
242
+ process.exit(1);
243
+ }
244
+ // Payload from args or stdin
245
+ let payload = filteredArgs[2] || '';
246
+ if (!payload) {
247
+ const stdinData = await readStdin();
248
+ if (stdinData)
249
+ payload = stdinData;
250
+ }
251
+ if (!payload)
252
+ payload = '{}';
253
+ await handleCall(SOCKET_PATH, filteredArgs[1], payload);
254
+ break;
255
+ }
256
+ case 'stats':
257
+ await handleStats(SOCKET_PATH);
258
+ break;
259
+ default:
260
+ console.error(`Unknown command: ${command}\n`);
261
+ console.log(USAGE);
262
+ process.exit(1);
263
+ }
264
+ }
265
+ main().catch((error) => {
266
+ console.error(`Error: ${error.message || error}`);
267
+ process.exit(1);
268
+ });
269
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,11 @@
1
+ import type { IPCResponse, IPCMethod } from '../types/index.js';
2
+ /**
3
+ * Sends a request to the daemon via Unix domain socket
4
+ * and waits for the response.
5
+ */
6
+ export declare function sendRequest(socketPath: string, method: IPCMethod, params?: Record<string, unknown>, timeoutMs?: number): Promise<IPCResponse>;
7
+ /**
8
+ * Check if the daemon is reachable by sending a status ping.
9
+ */
10
+ export declare function isDaemonRunning(socketPath: string): Promise<boolean>;
11
+ //# sourceMappingURL=ipc-client.d.ts.map
@@ -0,0 +1,81 @@
1
+ import net from 'net';
2
+ import { randomUUID } from 'crypto';
3
+ const DEFAULT_TIMEOUT_MS = 30000;
4
+ /**
5
+ * Sends a request to the daemon via Unix domain socket
6
+ * and waits for the response.
7
+ */
8
+ export async function sendRequest(socketPath, method, params, timeoutMs = DEFAULT_TIMEOUT_MS) {
9
+ const request = {
10
+ id: randomUUID(),
11
+ method,
12
+ params,
13
+ };
14
+ return new Promise((resolve, reject) => {
15
+ const socket = net.createConnection({ path: socketPath });
16
+ let buffer = '';
17
+ let settled = false;
18
+ const timer = setTimeout(() => {
19
+ if (!settled) {
20
+ settled = true;
21
+ socket.destroy();
22
+ reject(new Error(`Request timed out after ${timeoutMs}ms`));
23
+ }
24
+ }, timeoutMs);
25
+ socket.on('connect', () => {
26
+ socket.write(JSON.stringify(request) + '\n');
27
+ });
28
+ socket.on('data', (data) => {
29
+ buffer += data.toString();
30
+ const newlineIndex = buffer.indexOf('\n');
31
+ if (newlineIndex !== -1) {
32
+ const line = buffer.slice(0, newlineIndex);
33
+ if (!settled) {
34
+ settled = true;
35
+ clearTimeout(timer);
36
+ socket.destroy();
37
+ try {
38
+ const response = JSON.parse(line);
39
+ resolve(response);
40
+ }
41
+ catch {
42
+ reject(new Error('Invalid response from daemon'));
43
+ }
44
+ }
45
+ }
46
+ });
47
+ socket.on('error', (error) => {
48
+ if (!settled) {
49
+ settled = true;
50
+ clearTimeout(timer);
51
+ if (error.code === 'ECONNREFUSED' ||
52
+ error.code === 'ENOENT') {
53
+ reject(new Error('Daemon is not running'));
54
+ }
55
+ else {
56
+ reject(error);
57
+ }
58
+ }
59
+ });
60
+ socket.on('close', () => {
61
+ if (!settled) {
62
+ settled = true;
63
+ clearTimeout(timer);
64
+ reject(new Error('Connection closed before response'));
65
+ }
66
+ });
67
+ });
68
+ }
69
+ /**
70
+ * Check if the daemon is reachable by sending a status ping.
71
+ */
72
+ export async function isDaemonRunning(socketPath) {
73
+ try {
74
+ const response = await sendRequest(socketPath, 'daemon-status', undefined, 3000);
75
+ return !response.error;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ //# sourceMappingURL=ipc-client.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Intercepts large tool call outputs, saves them to a temp file,
3
+ * and returns a reference instead of the full payload.
4
+ */
5
+ export declare function interceptPayload(output: string, threshold?: number): string;
6
+ //# sourceMappingURL=payload-interceptor.d.ts.map
@@ -0,0 +1,49 @@
1
+ import { createHash } from 'crypto';
2
+ import { mkdtempSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ const DEFAULT_THRESHOLD = 500;
6
+ /**
7
+ * Private directory for intercepted payloads, created on first use.
8
+ *
9
+ * Writing `mcp_output_<hash>.txt` straight into the shared temp directory
10
+ * made the path predictable from the content alone: another local user could
11
+ * pre-plant a symlink there and turn our write into an arbitrary-file
12
+ * overwrite, or simply read whatever a backend MCP server had just returned.
13
+ * mkdtemp gives us a 0700 directory with an unpredictable name, so neither is
14
+ * reachable. The name is stable for the lifetime of the process, which keeps
15
+ * identical content mapping to a single file.
16
+ */
17
+ let payloadDir;
18
+ function getPayloadDir() {
19
+ if (payloadDir === undefined) {
20
+ payloadDir = mkdtempSync(join(tmpdir(), 'mcp-output-'));
21
+ }
22
+ return payloadDir;
23
+ }
24
+ /**
25
+ * Intercepts large tool call outputs, saves them to a temp file,
26
+ * and returns a reference instead of the full payload.
27
+ */
28
+ export function interceptPayload(output, threshold) {
29
+ const limit = threshold ?? DEFAULT_THRESHOLD;
30
+ if (output.length <= limit) {
31
+ return output;
32
+ }
33
+ const hash = createHash('sha256').update(output).digest('hex').slice(0, 12);
34
+ const filename = `mcp_output_${hash}.txt`;
35
+ const filepath = join(getPayloadDir(), filename);
36
+ try {
37
+ // 'wx' fails rather than following anything already at the path.
38
+ writeFileSync(filepath, output, { encoding: 'utf-8', mode: 0o600, flag: 'wx' });
39
+ }
40
+ catch (error) {
41
+ // Same content already written by this process - the existing file is the
42
+ // file we were about to write, so reuse it. Anything else is a real error.
43
+ if (error.code !== 'EEXIST') {
44
+ throw error;
45
+ }
46
+ }
47
+ return `Output saved to ${filepath} (${output.length} chars). Read file for full content.`;
48
+ }
49
+ //# sourceMappingURL=payload-interceptor.js.map
@@ -0,0 +1,53 @@
1
+ import type { MCPServerConfig } from '../types/index.js';
2
+ import { type InheritEnv, type CompressionFallbackBehavior } from './schema.js';
3
+ /**
4
+ * Check if tool name matches any ignore pattern
5
+ */
6
+ export declare function matchesIgnorePattern(toolName: string, patterns: string[]): boolean;
7
+ export type ConfigResult = {
8
+ servers: MCPServerConfig[];
9
+ excludePatterns: string[];
10
+ noCompressPatterns: string[];
11
+ defaultTimeout?: number;
12
+ cli?: {
13
+ payloadThreshold?: number;
14
+ autoStartDaemon?: boolean;
15
+ daemonLogLevel?: string;
16
+ };
17
+ inheritEnv?: InheritEnv;
18
+ compressionFallbackBehavior?: CompressionFallbackBehavior;
19
+ } | null;
20
+ /**
21
+ * Load and aggregate server configuration from JSON files
22
+ * 1. Load user-level config and collect patterns
23
+ * 2. Load project-level config and append servers
24
+ * 3. Aggregate exclude and noCompress patterns from both configs
25
+ */
26
+ export declare function loadJSONServers(): ConfigResult;
27
+ /**
28
+ * Cached wrapper around {@link loadJSONServers}.
29
+ *
30
+ * `tools/list` runs on every client refresh, and re-reading, re-validating and
31
+ * re-logging both config files each time is pure overhead. The cache is keyed
32
+ * on file mtime/size, so edits are still picked up without a restart.
33
+ */
34
+ export declare function loadJSONServersCached(): ConfigResult;
35
+ /**
36
+ * Drop the cached config. Primarily for tests that swap config files
37
+ * within a single process.
38
+ */
39
+ export declare function clearConfigCache(): void;
40
+ /**
41
+ * Get the path that would be used for config
42
+ * (for migration script purposes)
43
+ */
44
+ export declare function getConfigPath(): string;
45
+ /**
46
+ * Get the daemon Unix socket path
47
+ */
48
+ export declare function getSocketPath(): string;
49
+ /**
50
+ * Get the daemon PID file path
51
+ */
52
+ export declare function getPidFilePath(): string;
53
+ //# sourceMappingURL=loader.d.ts.map