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,246 @@
1
+ import { matchesIgnorePattern, loadJSONServersCached } from '../config/loader.js';
2
+ /** Render a token/char count compactly (1234 -> "1.2k"). */
3
+ function compact(value) {
4
+ if (value < 1000)
5
+ return `${value}`;
6
+ if (value < 1_000_000)
7
+ return `${(value / 1000).toFixed(1)}k`;
8
+ return `${(value / 1_000_000).toFixed(1)}M`;
9
+ }
10
+ export class StatsService {
11
+ logger;
12
+ clientManager;
13
+ compressionCache;
14
+ sessionManager;
15
+ configLoader;
16
+ constructor(logger, clientManager, compressionCache, sessionManager, configLoader = loadJSONServersCached) {
17
+ this.logger = logger;
18
+ this.clientManager = clientManager;
19
+ this.compressionCache = compressionCache;
20
+ this.sessionManager = sessionManager;
21
+ this.configLoader = configLoader;
22
+ }
23
+ async getStats(options) {
24
+ const detailLevel = options?.detailLevel === 'full' ? 'full' : 'summary';
25
+ const serverFilter = options?.serverName;
26
+ const config = this.configLoader() || {
27
+ servers: [],
28
+ excludePatterns: [],
29
+ noCompressPatterns: [],
30
+ };
31
+ const excludePatterns = config.excludePatterns || [];
32
+ const noCompressPatterns = config.noCompressPatterns || [];
33
+ const serverStatuses = this.clientManager.getServerStatuses();
34
+ const connectedClients = this.clientManager.getConnectedClients();
35
+ const connectedClientMap = new Map(connectedClients.map((c) => [c.name, c.client]));
36
+ const targetStatuses = serverFilter
37
+ ? serverStatuses.filter((s) => s.name === serverFilter)
38
+ : serverStatuses;
39
+ if (serverFilter && targetStatuses.length === 0) {
40
+ throw new Error(`Server '${serverFilter}' is not configured or not initialized`);
41
+ }
42
+ const serverStats = [];
43
+ for (const status of targetStatuses) {
44
+ if (!status.connected) {
45
+ serverStats.push({
46
+ name: status.name,
47
+ connected: false,
48
+ error: status.lastError || 'Not connected',
49
+ toolsTotal: 0,
50
+ toolsCompressed: 0,
51
+ toolsUncompressed: 0,
52
+ toolsExcluded: 0,
53
+ coveragePercent: 0,
54
+ originalChars: 0,
55
+ compressedChars: 0,
56
+ estimatedTokensSaved: 0,
57
+ });
58
+ continue;
59
+ }
60
+ const client = connectedClientMap.get(status.name);
61
+ if (!client) {
62
+ serverStats.push({
63
+ name: status.name,
64
+ connected: false,
65
+ error: 'Client not available',
66
+ toolsTotal: 0,
67
+ toolsCompressed: 0,
68
+ toolsUncompressed: 0,
69
+ toolsExcluded: 0,
70
+ coveragePercent: 0,
71
+ originalChars: 0,
72
+ compressedChars: 0,
73
+ estimatedTokensSaved: 0,
74
+ });
75
+ continue;
76
+ }
77
+ try {
78
+ const result = await client.listTools();
79
+ const filtered = result.tools.filter((tool) => !matchesIgnorePattern(`${status.name}__${tool.name}`, excludePatterns));
80
+ const excludedCount = result.tools.length - filtered.length;
81
+ let toolsCompressed = 0;
82
+ let originalChars = 0;
83
+ let compressedChars = 0;
84
+ for (const tool of filtered) {
85
+ const original = this.compressionCache.getOriginalDescription(status.name, tool.name) ??
86
+ tool.description ??
87
+ '';
88
+ const hasCompression = this.compressionCache.hasCompressed(status.name, tool.name);
89
+ const compressed = this.compressionCache.getCompressedDescription(status.name, tool.name) ??
90
+ (hasCompression ? '' : tool.description ?? '');
91
+ if (hasCompression) {
92
+ toolsCompressed += 1;
93
+ }
94
+ originalChars += original.length;
95
+ compressedChars += compressed.length;
96
+ }
97
+ const toolsTotal = filtered.length;
98
+ const toolsUncompressed = Math.max(toolsTotal - toolsCompressed, 0);
99
+ const estimatedTokensSaved = this.calculateTokensSaved(originalChars, compressedChars);
100
+ serverStats.push({
101
+ name: status.name,
102
+ connected: true,
103
+ toolsTotal,
104
+ toolsCompressed,
105
+ toolsUncompressed,
106
+ toolsExcluded: excludedCount,
107
+ coveragePercent: this.coverage(toolsCompressed, toolsTotal),
108
+ originalChars,
109
+ compressedChars,
110
+ estimatedTokensSaved,
111
+ });
112
+ }
113
+ catch (error) {
114
+ const message = error instanceof Error ? error.message : 'Unknown error';
115
+ this.logger.warn({ server: status.name, error: message }, 'Failed to list tools for stats');
116
+ serverStats.push({
117
+ name: status.name,
118
+ connected: true,
119
+ error: message,
120
+ toolsTotal: 0,
121
+ toolsCompressed: 0,
122
+ toolsUncompressed: 0,
123
+ toolsExcluded: 0,
124
+ coveragePercent: 0,
125
+ originalChars: 0,
126
+ compressedChars: 0,
127
+ estimatedTokensSaved: 0,
128
+ });
129
+ }
130
+ }
131
+ const aggregateTotalTools = serverStats.reduce((sum, s) => sum + s.toolsTotal, 0);
132
+ const aggregateCompressed = serverStats.reduce((sum, s) => sum + s.toolsCompressed, 0);
133
+ const aggregateOriginalChars = serverStats.reduce((sum, s) => sum + s.originalChars, 0);
134
+ const aggregateCompressedChars = serverStats.reduce((sum, s) => sum + s.compressedChars, 0);
135
+ const cacheMetrics = this.compressionCache.getCacheMetrics();
136
+ const sessions = this.sessionManager.getAllSessions();
137
+ const payload = {
138
+ summary: {
139
+ serversConfigured: config.servers?.length || serverStatuses.length,
140
+ serversConnected: serverStatuses.filter((s) => s.connected).length,
141
+ serversWithErrors: serverStatuses.filter((s) => !s.connected || s.lastError).length,
142
+ toolsTotal: aggregateTotalTools,
143
+ toolsCompressed: aggregateCompressed,
144
+ toolsUncompressed: Math.max(aggregateTotalTools - aggregateCompressed, 0),
145
+ coveragePercent: this.coverage(aggregateCompressed, aggregateTotalTools),
146
+ originalChars: aggregateOriginalChars,
147
+ compressedChars: aggregateCompressedChars,
148
+ estimatedTokensSaved: this.calculateTokensSaved(aggregateOriginalChars, aggregateCompressedChars),
149
+ },
150
+ servers: serverStats,
151
+ compression: {
152
+ cacheEntries: cacheMetrics.totalCached,
153
+ cacheFilePath: this.compressionCache.getCacheFilePath(),
154
+ cacheSizeBytes: cacheMetrics.cacheSizeBytes,
155
+ missingOriginals: cacheMetrics.missingOriginals,
156
+ latestCompressedAt: cacheMetrics.latestCompressedAt,
157
+ totalOriginalChars: cacheMetrics.totalOriginalChars,
158
+ totalCompressedChars: cacheMetrics.totalCompressedChars,
159
+ estimatedTokensSaved: this.calculateTokensSaved(cacheMetrics.totalOriginalChars, cacheMetrics.totalCompressedChars),
160
+ },
161
+ sessions: {
162
+ activeSessions: sessions.length,
163
+ expandedToolsTotal: sessions.reduce((sum, session) => sum + session.expandedTools.length, 0),
164
+ sessions: detailLevel === 'full'
165
+ ? sessions.map((session) => ({
166
+ sessionId: session.sessionId,
167
+ expandedToolsCount: session.expandedTools.length,
168
+ }))
169
+ : undefined,
170
+ },
171
+ config: {
172
+ excludePatterns,
173
+ noCompressPatterns,
174
+ },
175
+ };
176
+ return payload;
177
+ }
178
+ /**
179
+ * Compute coverage over tools already fetched during aggregation.
180
+ *
181
+ * Unlike {@link getStats} this issues no `listTools` calls, so it is cheap
182
+ * enough to run on every `tools/list`.
183
+ */
184
+ computeCoverage(tools) {
185
+ let compressedTools = 0;
186
+ let originalChars = 0;
187
+ let compressedChars = 0;
188
+ let latestCompressedAt;
189
+ for (const tool of tools) {
190
+ const cachedOriginal = this.compressionCache.getOriginalDescription(tool.serverName, tool.toolName);
191
+ const original = cachedOriginal ?? tool.description ?? '';
192
+ const compressed = this.compressionCache.getCompressedDescription(tool.serverName, tool.toolName);
193
+ originalChars += original.length;
194
+ if (compressed !== undefined) {
195
+ compressedTools += 1;
196
+ compressedChars += compressed.length;
197
+ }
198
+ else {
199
+ // Uncompressed tools still occupy their original description.
200
+ compressedChars += original.length;
201
+ }
202
+ }
203
+ for (const entry of this.compressionCache.getCacheEntries()) {
204
+ if (!latestCompressedAt || latestCompressedAt < entry.compressedAt) {
205
+ latestCompressedAt = entry.compressedAt;
206
+ }
207
+ }
208
+ return {
209
+ totalTools: tools.length,
210
+ compressedTools,
211
+ uncompressedTools: Math.max(tools.length - compressedTools, 0),
212
+ coveragePercent: this.coverage(compressedTools, tools.length),
213
+ originalChars,
214
+ compressedChars,
215
+ estimatedTokensSaved: this.calculateTokensSaved(originalChars, compressedChars),
216
+ latestCompressedAt,
217
+ };
218
+ }
219
+ /**
220
+ * One-line coverage summary suitable for appending to a tool description.
221
+ * Kept terse - it ships on every `tools/list`.
222
+ */
223
+ formatCoverage(coverage) {
224
+ if (coverage.totalTools === 0) {
225
+ return '[live: no backend tools available]';
226
+ }
227
+ const parts = [
228
+ `${coverage.compressedTools}/${coverage.totalTools} compressed (${coverage.coveragePercent}%)`,
229
+ `${coverage.uncompressedTools} remaining`,
230
+ ];
231
+ if (coverage.estimatedTokensSaved > 0) {
232
+ parts.push(`~${compact(coverage.estimatedTokensSaved)} tokens saved`);
233
+ }
234
+ return `[live: ${parts.join(' · ')}]`;
235
+ }
236
+ coverage(done, total) {
237
+ if (!total)
238
+ return 0;
239
+ return Math.round((done / total) * 1000) / 10; // one decimal place
240
+ }
241
+ calculateTokensSaved(originalChars, compressedChars) {
242
+ const savedChars = Math.max(originalChars - compressedChars, 0);
243
+ return Math.round(savedChars / 4); // rough char->token estimate
244
+ }
245
+ }
246
+ //# sourceMappingURL=stats-service.js.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Types for compression and session management
3
+ */
4
+ export interface CompressedToolCache {
5
+ [key: string]: {
6
+ original?: string;
7
+ compressed: string;
8
+ compressedAt: string;
9
+ };
10
+ }
11
+ export interface CompressionStats {
12
+ totalTools: number;
13
+ compressedTools: number;
14
+ expandedTools: string[];
15
+ cacheSize: number;
16
+ }
17
+ export interface SessionInfo {
18
+ sessionId: string;
19
+ createdAt: string;
20
+ lastAccessedAt: string;
21
+ expandedTools: string[];
22
+ }
23
+ export interface CacheMetrics {
24
+ totalCached: number;
25
+ totalOriginalChars: number;
26
+ totalCompressedChars: number;
27
+ missingOriginals: number;
28
+ latestCompressedAt?: string;
29
+ cacheSizeBytes: number;
30
+ perServer: Record<string, {
31
+ cached: number;
32
+ totalOriginalChars: number;
33
+ totalCompressedChars: number;
34
+ missingOriginals: number;
35
+ latestCompressedAt?: string;
36
+ }>;
37
+ }
38
+ //# sourceMappingURL=compression.d.ts.map
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Types for compression and session management
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=compression.js.map
@@ -0,0 +1,108 @@
1
+ import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
3
+ export interface MCPServerConfig {
4
+ name: string;
5
+ command: string;
6
+ args?: string[];
7
+ env?: Record<string, string>;
8
+ /** Which of the proxy's env vars to pass through. Defaults to all. */
9
+ inheritEnv?: boolean | string[];
10
+ enabled?: boolean;
11
+ timeout?: number;
12
+ }
13
+ export interface MCPClientConnection {
14
+ name: string;
15
+ client: Client;
16
+ transport: Transport;
17
+ connected: boolean;
18
+ lastError?: string;
19
+ }
20
+ export interface AggregatedToolsResponse {
21
+ tools: Array<{
22
+ name: string;
23
+ description?: string;
24
+ inputSchema: object;
25
+ serverName: string;
26
+ }>;
27
+ totalServers: number;
28
+ connectedServers: number;
29
+ }
30
+ export interface ToolCallRequest {
31
+ serverName: string;
32
+ toolName: string;
33
+ arguments?: Record<string, unknown>;
34
+ }
35
+ export interface ToolCallResponse {
36
+ content: Array<{
37
+ type: string;
38
+ text?: string;
39
+ [key: string]: unknown;
40
+ }>;
41
+ isError?: boolean;
42
+ }
43
+ export interface ServerStatus {
44
+ name: string;
45
+ connected: boolean;
46
+ lastError?: string;
47
+ }
48
+ export interface HealthResponse {
49
+ status: 'healthy' | 'degraded' | 'unhealthy';
50
+ servers: ServerStatus[];
51
+ timestamp: string;
52
+ }
53
+ export interface FilterOptions {
54
+ serverNames?: string[];
55
+ namePattern?: string;
56
+ excludeServers?: string[];
57
+ }
58
+ export interface ToolsQueryParams {
59
+ servers?: string;
60
+ exclude?: string;
61
+ pattern?: string;
62
+ sessionId?: string;
63
+ }
64
+ export type IPCMethod = 'tools' | 'search' | 'info' | 'call' | 'stats' | 'daemon-status';
65
+ export interface IPCRequest {
66
+ id: string;
67
+ method: IPCMethod;
68
+ params?: Record<string, unknown>;
69
+ }
70
+ export interface IPCResponse {
71
+ id: string;
72
+ result?: unknown;
73
+ error?: {
74
+ code: number;
75
+ message: string;
76
+ };
77
+ }
78
+ export interface CLIConfig {
79
+ payloadThreshold?: number;
80
+ autoStartDaemon?: boolean;
81
+ daemonLogLevel?: string;
82
+ }
83
+ export interface DaemonStatusResult {
84
+ running: boolean;
85
+ pid: number;
86
+ uptime: number;
87
+ servers: ServerStatus[];
88
+ /**
89
+ * Tools with a cached compressed description. Deliberately not the total
90
+ * tool count: daemon-status doubles as the liveness ping on every CLI
91
+ * invocation, and counting all tools would mean a listTools round-trip per
92
+ * connected server on every command.
93
+ */
94
+ cachedToolCount: number;
95
+ socketPath: string;
96
+ }
97
+ export interface ToolEntry {
98
+ server: string;
99
+ tool: string;
100
+ description: string;
101
+ }
102
+ export interface ToolInfoResult {
103
+ name: string;
104
+ server: string;
105
+ description: string;
106
+ inputSchema: object;
107
+ }
108
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Identity this proxy reports to MCP peers during the initialize handshake.
3
+ *
4
+ * VERSION is kept in sync with package.json by `npm run sync-version`, which
5
+ * semantic-release runs during a release (see .releaserc.json). The
6
+ * `tests/unit/version.test.ts` guard fails CI if the two ever drift, so this
7
+ * constant never needs to be edited by hand.
8
+ */
9
+ export declare const SERVER_NAME = "mcp-compression-proxy";
10
+ export declare const VERSION = "1.0.0";
11
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Identity this proxy reports to MCP peers during the initialize handshake.
3
+ *
4
+ * VERSION is kept in sync with package.json by `npm run sync-version`, which
5
+ * semantic-release runs during a release (see .releaserc.json). The
6
+ * `tests/unit/version.test.ts` guard fails CI if the two ever drift, so this
7
+ * constant never needs to be edited by hand.
8
+ */
9
+ export const SERVER_NAME = 'mcp-compression-proxy';
10
+ export const VERSION = '1.0.0';
11
+ //# sourceMappingURL=version.js.map
package/package.json ADDED
@@ -0,0 +1,110 @@
1
+ {
2
+ "name": "mcp-compression-proxy",
3
+ "version": "1.0.0",
4
+ "description": "MCP server that aggregates tools from multiple MCP servers with LLM-based description compression. Reduces context consumption by 50-80% while maintaining full tool functionality.",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "build:test-servers": "tsc -p tests/e2e-real/test-servers/tsconfig.json",
10
+ "watch": "tsc --watch",
11
+ "dev": "tsx watch src/index.ts",
12
+ "start": "node dist/index.js",
13
+ "typecheck": "tsc --noEmit",
14
+ "lint": "eslint .",
15
+ "lint:fix": "eslint . --fix",
16
+ "format": "prettier --write \"**/*.{ts,json,md}\"",
17
+ "format:check": "prettier --check \"**/*.{ts,json,md}\"",
18
+ "pretest": "npm run build",
19
+ "test": "jest",
20
+ "test:watch": "jest --watch",
21
+ "test:coverage": "jest --coverage",
22
+ "test:unit": "jest --testPathPatterns=unit",
23
+ "pretest:integration": "npm run build",
24
+ "test:integration": "jest --testPathPatterns=integration",
25
+ "pretest:e2e": "npm run build",
26
+ "test:e2e": "jest --testPathPatterns=e2e",
27
+ "test:e2e:real-llm": "npm run build && npm run build:test-servers && jest tests/e2e-real --testTimeout=120000 --testPathIgnorePatterns='/node_modules/'",
28
+ "docker:e2e": "docker-compose -f docker-compose.e2e.yml up --abort-on-container-exit",
29
+ "cli:start": "node dist/cli/index.js daemon start",
30
+ "cli:stop": "node dist/cli/index.js daemon stop",
31
+ "sync-version": "node scripts/sync-version.mjs",
32
+ "prepare": "husky || true",
33
+ "prepublishOnly": "npm run build && npm test"
34
+ },
35
+ "bin": {
36
+ "mcp-compression-proxy": "./dist/index.js",
37
+ "mcp-cli": "./dist/cli/index.js"
38
+ },
39
+ "files": [
40
+ "dist/**/*.js",
41
+ "dist/**/*.d.ts",
42
+ "!dist/**/*.test.js",
43
+ "!dist/**/*.test.d.ts",
44
+ "!dist/**/*.spec.js",
45
+ "!dist/**/*.spec.d.ts",
46
+ "README.md",
47
+ "LICENSE",
48
+ "CHANGELOG.md"
49
+ ],
50
+ "keywords": [
51
+ "mcp",
52
+ "model-context-protocol",
53
+ "aggregator",
54
+ "compression",
55
+ "llm",
56
+ "claude",
57
+ "claude-desktop",
58
+ "cline",
59
+ "ai",
60
+ "context-optimization",
61
+ "tool-aggregation"
62
+ ],
63
+ "author": "KDPA",
64
+ "license": "MIT",
65
+ "repository": {
66
+ "type": "git",
67
+ "url": "git+https://github.com/kdpa-llc/mcp-compression-proxy.git"
68
+ },
69
+ "homepage": "https://github.com/kdpa-llc/mcp-compression-proxy#readme",
70
+ "bugs": {
71
+ "url": "https://github.com/kdpa-llc/mcp-compression-proxy/issues"
72
+ },
73
+ "dependencies": {
74
+ "@modelcontextprotocol/sdk": "^1.0.4",
75
+ "ajv": "^8.17.1",
76
+ "pino": "^10.3.1",
77
+ "pino-pretty": "^13.0.0"
78
+ },
79
+ "devDependencies": {
80
+ "@commitlint/cli": "^21.2.2",
81
+ "@commitlint/config-conventional": "^21.2.2",
82
+ "@eslint/js": "^10.0.1",
83
+ "@jest/globals": "^30.4.1",
84
+ "@semantic-release/changelog": "^6.0.3",
85
+ "@semantic-release/commit-analyzer": "^13.0.1",
86
+ "@semantic-release/exec": "^7.1.0",
87
+ "@semantic-release/git": "^10.0.1",
88
+ "@semantic-release/github": "^12.0.2",
89
+ "@semantic-release/npm": "^13.1.2",
90
+ "@semantic-release/release-notes-generator": "^14.1.0",
91
+ "@types/jest": "^30.0.0",
92
+ "@types/node": "^24.13.3",
93
+ "conventional-changelog-conventionalcommits": "^9.3.1",
94
+ "eslint": "^10.8.1",
95
+ "eslint-config-prettier": "^10.1.8",
96
+ "globals": "^17.11.0",
97
+ "husky": "^9.1.7",
98
+ "jest": "^30.4.2",
99
+ "prettier": "^3.9.6",
100
+ "semantic-release": "^25.0.2",
101
+ "ts-jest": "^29.2.5",
102
+ "ts-node": "^10.9.2",
103
+ "tsx": "^4.19.2",
104
+ "typescript": "^5.9.3",
105
+ "typescript-eslint": "^8.67.0"
106
+ },
107
+ "engines": {
108
+ "node": ">=22.0.0"
109
+ }
110
+ }