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.
- package/CHANGELOG.md +97 -0
- package/LICENSE +21 -0
- package/README.md +842 -0
- package/dist/cli/commands.d.ts +25 -0
- package/dist/cli/commands.js +152 -0
- package/dist/cli/daemon.d.ts +4 -0
- package/dist/cli/daemon.js +336 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +269 -0
- package/dist/cli/ipc-client.d.ts +11 -0
- package/dist/cli/ipc-client.js +81 -0
- package/dist/cli/payload-interceptor.d.ts +6 -0
- package/dist/cli/payload-interceptor.js +49 -0
- package/dist/config/loader.d.ts +53 -0
- package/dist/config/loader.js +332 -0
- package/dist/config/schema.d.ts +164 -0
- package/dist/config/schema.js +127 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +821 -0
- package/dist/mcp/client-manager.d.ts +65 -0
- package/dist/mcp/client-manager.js +197 -0
- package/dist/services/compression-cache.d.ts +112 -0
- package/dist/services/compression-cache.js +238 -0
- package/dist/services/compression-persistence.d.ts +36 -0
- package/dist/services/compression-persistence.js +111 -0
- package/dist/services/compression-sampler.d.ts +89 -0
- package/dist/services/compression-sampler.js +171 -0
- package/dist/services/session-manager.d.ts +64 -0
- package/dist/services/session-manager.js +160 -0
- package/dist/services/stats-service.d.ts +101 -0
- package/dist/services/stats-service.js +246 -0
- package/dist/types/compression.d.ts +38 -0
- package/dist/types/compression.js +5 -0
- package/dist/types/index.d.ts +108 -0
- package/dist/types/index.js +2 -0
- package/dist/version.d.ts +11 -0
- package/dist/version.js +11 -0
- package/package.json +110 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { MCPClientManager } from './mcp/client-manager.js';
|
|
6
|
+
import { CompressionCache } from './services/compression-cache.js';
|
|
7
|
+
import { SessionManager } from './services/session-manager.js';
|
|
8
|
+
import { loadJSONServersCached, matchesIgnorePattern } from './config/loader.js';
|
|
9
|
+
import { writeFileSync, readFileSync } from 'fs';
|
|
10
|
+
import { resolve } from 'path';
|
|
11
|
+
import pino from 'pino';
|
|
12
|
+
import { StatsService } from './services/stats-service.js';
|
|
13
|
+
import { CompressionSampler } from './services/compression-sampler.js';
|
|
14
|
+
import { SERVER_NAME, VERSION } from './version.js';
|
|
15
|
+
/**
|
|
16
|
+
* MCP Server that aggregates tools from multiple MCP servers
|
|
17
|
+
* with LLM-based description compression
|
|
18
|
+
*/
|
|
19
|
+
const logger = pino({
|
|
20
|
+
name: 'mcp-compression-proxy',
|
|
21
|
+
level: process.env.LOG_LEVEL || 'info',
|
|
22
|
+
transport: {
|
|
23
|
+
target: 'pino-pretty',
|
|
24
|
+
options: {
|
|
25
|
+
colorize: false,
|
|
26
|
+
translateTime: 'HH:MM:ss Z',
|
|
27
|
+
ignore: 'pid,hostname',
|
|
28
|
+
destination: 2, // Forces output to stderr (FD 2) to keep stdout clean for MCP JSON-RPC
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
// Initialize services
|
|
33
|
+
const clientManager = new MCPClientManager(logger);
|
|
34
|
+
const compressionCache = new CompressionCache(logger);
|
|
35
|
+
const sessionManager = new SessionManager(logger);
|
|
36
|
+
const statsService = new StatsService(logger, clientManager, compressionCache, sessionManager);
|
|
37
|
+
// Current session context (set by tools)
|
|
38
|
+
let currentSessionId;
|
|
39
|
+
// Create MCP server
|
|
40
|
+
const server = new Server({
|
|
41
|
+
name: SERVER_NAME,
|
|
42
|
+
version: VERSION,
|
|
43
|
+
}, {
|
|
44
|
+
capabilities: {
|
|
45
|
+
tools: {},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
// Compresses via the host's own LLM when the client supports sampling.
|
|
49
|
+
const compressionSampler = new CompressionSampler(logger, {
|
|
50
|
+
getClientCapabilities: () => server.getClientCapabilities(),
|
|
51
|
+
createMessage: (params) => server.createMessage(params),
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* Fetch every tool from every connected backend server, once.
|
|
55
|
+
*
|
|
56
|
+
* Callers that need both the tool list and derived counts should reuse a single
|
|
57
|
+
* snapshot rather than calling `listTools` per tool.
|
|
58
|
+
*/
|
|
59
|
+
async function fetchAllBackendTools() {
|
|
60
|
+
const clients = clientManager.getConnectedClients();
|
|
61
|
+
const perServer = await Promise.all(clients.map(async ({ name, client }) => {
|
|
62
|
+
try {
|
|
63
|
+
const result = await client.listTools();
|
|
64
|
+
return result.tools.map((tool) => ({
|
|
65
|
+
serverName: name,
|
|
66
|
+
toolName: tool.name,
|
|
67
|
+
description: tool.description,
|
|
68
|
+
inputSchema: tool.inputSchema,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
logger.error({ server: name, error }, 'Failed to list tools from server');
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}));
|
|
76
|
+
return perServer.flat();
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* List all tools from aggregated MCP servers + management tools
|
|
80
|
+
*/
|
|
81
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
82
|
+
logger.debug('Handling tools/list request');
|
|
83
|
+
// Fetch backend tools first so the management tools can advertise live
|
|
84
|
+
// coverage numbers derived from this same snapshot.
|
|
85
|
+
const backendTools = await fetchAllBackendTools();
|
|
86
|
+
const coverage = statsService.computeCoverage(backendTools);
|
|
87
|
+
const liveStats = statsService.formatCoverage(coverage);
|
|
88
|
+
const aggregatorTools = [
|
|
89
|
+
{
|
|
90
|
+
name: 'mcp-compression-proxy__create_session',
|
|
91
|
+
description: 'Create a new session for independent tool expansion control',
|
|
92
|
+
inputSchema: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'mcp-compression-proxy__delete_session',
|
|
99
|
+
description: 'Delete a session',
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
sessionId: {
|
|
104
|
+
type: 'string',
|
|
105
|
+
description: 'Session ID to delete',
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
required: ['sessionId'],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'mcp-compression-proxy__set_session',
|
|
113
|
+
description: 'Set the active session for subsequent tool calls (affects which tools show expanded descriptions)',
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
sessionId: {
|
|
118
|
+
type: 'string',
|
|
119
|
+
description: 'Session ID to use (from create_session)',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
required: ['sessionId'],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'mcp-compression-proxy__clear_compressed_tools_cache',
|
|
127
|
+
description: 'Clear all cached compressed tool descriptions. Use this to start fresh with compression or when tool descriptions have changed significantly.',
|
|
128
|
+
inputSchema: {
|
|
129
|
+
type: 'object',
|
|
130
|
+
properties: {},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'mcp-compression-proxy__get_uncompressed_tools',
|
|
135
|
+
description: `Get tools that need compression (those without cached compressed descriptions). Returns up to the specified limit of tools that need compression. After compressing these descriptions, call mcp-compression-proxy__cache_compressed_tools. Repeat this process until no uncached tools remain. ${liveStats}`,
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {
|
|
139
|
+
limit: {
|
|
140
|
+
type: 'number',
|
|
141
|
+
description: 'Maximum number of tools to return (default: 25, max: 100)',
|
|
142
|
+
minimum: 1,
|
|
143
|
+
maximum: 100,
|
|
144
|
+
default: 25,
|
|
145
|
+
},
|
|
146
|
+
outputFile: {
|
|
147
|
+
type: 'string',
|
|
148
|
+
description: 'Optional file path to write tools JSON instead of returning as text',
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'mcp-compression-proxy__cache_compressed_tools',
|
|
155
|
+
description: `Save compressed tool descriptions to cache (max 100 tools per call). Provide either descriptions array or inputFile path. After caching, call mcp-compression-proxy__get_uncompressed_tools again to get the next batch if any remain uncached. Continue until all tools are compressed. ${liveStats}`,
|
|
156
|
+
inputSchema: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
properties: {
|
|
159
|
+
descriptions: {
|
|
160
|
+
type: 'array',
|
|
161
|
+
description: 'Array of compressed tool descriptions (max 100). Use this OR inputFile, not both.',
|
|
162
|
+
maxItems: 100,
|
|
163
|
+
items: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: {
|
|
166
|
+
serverName: { type: 'string' },
|
|
167
|
+
toolName: { type: 'string' },
|
|
168
|
+
description: { type: 'string' },
|
|
169
|
+
},
|
|
170
|
+
required: ['serverName', 'toolName', 'description'],
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
inputFile: {
|
|
174
|
+
type: 'string',
|
|
175
|
+
description: 'File path to read compressed tools JSON. Use this OR descriptions, not both.',
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: 'mcp-compression-proxy__expand_tool',
|
|
182
|
+
description: 'Expand a tool to show its full original description (session-specific)',
|
|
183
|
+
inputSchema: {
|
|
184
|
+
type: 'object',
|
|
185
|
+
properties: {
|
|
186
|
+
serverName: {
|
|
187
|
+
type: 'string',
|
|
188
|
+
description: 'Server name (e.g., "filesystem")',
|
|
189
|
+
},
|
|
190
|
+
toolName: {
|
|
191
|
+
type: 'string',
|
|
192
|
+
description: 'Tool name (e.g., "read_file")',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
required: ['serverName', 'toolName'],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'mcp-compression-proxy__collapse_tool',
|
|
200
|
+
description: 'Collapse a tool back to compressed description (session-specific)',
|
|
201
|
+
inputSchema: {
|
|
202
|
+
type: 'object',
|
|
203
|
+
properties: {
|
|
204
|
+
serverName: {
|
|
205
|
+
type: 'string',
|
|
206
|
+
description: 'Server name',
|
|
207
|
+
},
|
|
208
|
+
toolName: {
|
|
209
|
+
type: 'string',
|
|
210
|
+
description: 'Tool name',
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
required: ['serverName', 'toolName'],
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: 'mcp-compression-proxy__compress_via_sampling',
|
|
218
|
+
description: `Compress uncached tool descriptions automatically using this client's own LLM, via MCP sampling. Requires a client that supports sampling; returns an error explaining the manual alternative if it does not. No API key or extra configuration needed. ${liveStats}`,
|
|
219
|
+
inputSchema: {
|
|
220
|
+
type: 'object',
|
|
221
|
+
properties: {
|
|
222
|
+
limit: {
|
|
223
|
+
type: 'number',
|
|
224
|
+
description: 'Maximum number of tools to compress in this call (default: 25, max: 100)',
|
|
225
|
+
minimum: 1,
|
|
226
|
+
maximum: 100,
|
|
227
|
+
default: 25,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: 'mcp-compression-proxy__stats',
|
|
234
|
+
description: 'Get compression and server statistics. Optional inputs: serverName filter and detailLevel ("summary" | "full", default summary). Returns JSON with coverage, cache, and session details.',
|
|
235
|
+
inputSchema: {
|
|
236
|
+
type: 'object',
|
|
237
|
+
properties: {
|
|
238
|
+
serverName: {
|
|
239
|
+
type: 'string',
|
|
240
|
+
description: 'Optional server name to scope stats to a single backend server',
|
|
241
|
+
},
|
|
242
|
+
detailLevel: {
|
|
243
|
+
type: 'string',
|
|
244
|
+
description: 'Detail level for stats ("summary" | "full")',
|
|
245
|
+
enum: ['summary', 'full'],
|
|
246
|
+
default: 'summary',
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
const aggregatedTools = backendTools.map((tool) => {
|
|
253
|
+
// Check if tool is expanded in current session
|
|
254
|
+
const isExpanded = sessionManager.isToolExpanded(currentSessionId, tool.serverName, tool.toolName);
|
|
255
|
+
// Get description: compressed by default, original if expanded
|
|
256
|
+
const description = compressionCache.getDescription(tool.serverName, tool.toolName, tool.description, isExpanded);
|
|
257
|
+
return {
|
|
258
|
+
name: `${tool.serverName}__${tool.toolName}`,
|
|
259
|
+
description,
|
|
260
|
+
inputSchema: tool.inputSchema,
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
const allTools = [...aggregatorTools, ...aggregatedTools];
|
|
264
|
+
// Apply exclude patterns to filter out tools
|
|
265
|
+
const config = loadJSONServersCached();
|
|
266
|
+
const excludePatterns = config?.excludePatterns || [];
|
|
267
|
+
const filteredTools = allTools.filter(tool => {
|
|
268
|
+
const isExcluded = matchesIgnorePattern(tool.name, excludePatterns);
|
|
269
|
+
if (isExcluded) {
|
|
270
|
+
logger.debug({ tool: tool.name }, 'Tool excluded by pattern');
|
|
271
|
+
}
|
|
272
|
+
return !isExcluded;
|
|
273
|
+
});
|
|
274
|
+
logger.debug({ count: filteredTools.length, excluded: allTools.length - filteredTools.length }, 'Returning tools');
|
|
275
|
+
return { tools: filteredTools };
|
|
276
|
+
});
|
|
277
|
+
/**
|
|
278
|
+
* Call a tool (either management tool or aggregated MCP tool)
|
|
279
|
+
*/
|
|
280
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
281
|
+
const { name, arguments: args } = request.params;
|
|
282
|
+
logger.debug({ tool: name, args }, 'Handling tools/call request');
|
|
283
|
+
// Management tools
|
|
284
|
+
if (name === 'mcp-compression-proxy__create_session') {
|
|
285
|
+
const sessionId = sessionManager.createSession();
|
|
286
|
+
currentSessionId = sessionId;
|
|
287
|
+
return {
|
|
288
|
+
content: [
|
|
289
|
+
{
|
|
290
|
+
type: 'text',
|
|
291
|
+
text: `Session created: ${sessionId}\n\nThis session is now active. Tools expanded in this session will show full descriptions.`,
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (name === 'mcp-compression-proxy__delete_session') {
|
|
297
|
+
const { sessionId } = args;
|
|
298
|
+
const deleted = sessionManager.deleteSession(sessionId);
|
|
299
|
+
if (currentSessionId === sessionId) {
|
|
300
|
+
currentSessionId = undefined;
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
content: [
|
|
304
|
+
{
|
|
305
|
+
type: 'text',
|
|
306
|
+
text: deleted
|
|
307
|
+
? `Session ${sessionId} deleted successfully.`
|
|
308
|
+
: `Session ${sessionId} not found.`,
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (name === 'mcp-compression-proxy__set_session') {
|
|
314
|
+
const { sessionId } = args;
|
|
315
|
+
if (!sessionManager.hasSession(sessionId)) {
|
|
316
|
+
return {
|
|
317
|
+
content: [
|
|
318
|
+
{
|
|
319
|
+
type: 'text',
|
|
320
|
+
text: `Error: Session ${sessionId} not found. Create a session first with mcp-compression-proxy__create_session.`,
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
isError: true,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
currentSessionId = sessionId;
|
|
327
|
+
return {
|
|
328
|
+
content: [
|
|
329
|
+
{
|
|
330
|
+
type: 'text',
|
|
331
|
+
text: `Active session set to: ${sessionId}`,
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
if (name === 'mcp-compression-proxy__clear_compressed_tools_cache') {
|
|
337
|
+
try {
|
|
338
|
+
await compressionCache.clearAll();
|
|
339
|
+
logger.info('Compression cache cleared');
|
|
340
|
+
return {
|
|
341
|
+
content: [
|
|
342
|
+
{
|
|
343
|
+
type: 'text',
|
|
344
|
+
text: 'Successfully cleared all cached compressed tool descriptions.',
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
logger.error({ error }, 'Failed to clear cache');
|
|
351
|
+
return {
|
|
352
|
+
content: [
|
|
353
|
+
{
|
|
354
|
+
type: 'text',
|
|
355
|
+
text: `Error clearing cache: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
isError: true,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (name === 'mcp-compression-proxy__get_uncompressed_tools') {
|
|
363
|
+
const { limit = 25, outputFile } = args;
|
|
364
|
+
const actualLimit = Math.min(Math.max(limit, 1), 100);
|
|
365
|
+
const backendTools = await fetchAllBackendTools();
|
|
366
|
+
const coverage = statsService.computeCoverage(backendTools);
|
|
367
|
+
const liveStats = statsService.formatCoverage(coverage);
|
|
368
|
+
const allUncompressedTools = backendTools
|
|
369
|
+
.filter((tool) => !compressionCache.hasCompressed(tool.serverName, tool.toolName))
|
|
370
|
+
.map((tool) => ({
|
|
371
|
+
serverName: tool.serverName,
|
|
372
|
+
toolName: tool.toolName,
|
|
373
|
+
description: tool.description || '',
|
|
374
|
+
}));
|
|
375
|
+
// Apply limit
|
|
376
|
+
const toolsToCompress = allUncompressedTools.slice(0, actualLimit);
|
|
377
|
+
const remaining = Math.max(0, allUncompressedTools.length - actualLimit);
|
|
378
|
+
if (outputFile) {
|
|
379
|
+
// Write tools to file instead of returning as text
|
|
380
|
+
try {
|
|
381
|
+
const filePath = resolve(outputFile);
|
|
382
|
+
writeFileSync(filePath, JSON.stringify(toolsToCompress, null, 2), 'utf-8');
|
|
383
|
+
logger.info({ filePath, count: toolsToCompress.length }, 'Wrote tools to file');
|
|
384
|
+
return {
|
|
385
|
+
content: [
|
|
386
|
+
{
|
|
387
|
+
type: 'text',
|
|
388
|
+
text: `Found ${allUncompressedTools.length} tools without compressed descriptions.\n\nWrote ${toolsToCompress.length} tools to file: ${filePath}\n\nRemaining uncached tools: ${remaining}\n\n${liveStats}\n\nAfter compressing the descriptions in the file, call mcp-compression-proxy__cache_compressed_tools with inputFile parameter.${remaining > 0 ? '\n\nThen call mcp-compression-proxy__get_uncompressed_tools again to get the next batch.' : ''}`,
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
logger.error({ outputFile, error }, 'Failed to write tools to file');
|
|
395
|
+
return {
|
|
396
|
+
content: [
|
|
397
|
+
{
|
|
398
|
+
type: 'text',
|
|
399
|
+
text: `Error writing tools to file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
isError: true,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// Original behavior: return as text
|
|
407
|
+
return {
|
|
408
|
+
content: [
|
|
409
|
+
{
|
|
410
|
+
type: 'text',
|
|
411
|
+
text: `Found ${allUncompressedTools.length} tools without compressed descriptions.\n\nReturning ${toolsToCompress.length} tools for compression (limit: ${actualLimit}).\n\nRemaining uncached tools: ${remaining}\n\n${liveStats}\n\nTools to compress:\n\n${JSON.stringify(toolsToCompress, null, 2)}\n\nAfter compressing these descriptions, call mcp-compression-proxy__cache_compressed_tools with the results.${remaining > 0 ? '\n\nThen call mcp-compression-proxy__get_uncompressed_tools again to get the next batch.' : ''}`,
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
if (name === 'mcp-compression-proxy__cache_compressed_tools') {
|
|
417
|
+
const { descriptions, inputFile } = args;
|
|
418
|
+
// Validate that exactly one parameter is provided. The "neither" case is
|
|
419
|
+
// handled by the final else below, which lets the compiler narrow
|
|
420
|
+
// `descriptions` instead of needing a non-null assertion.
|
|
421
|
+
if (descriptions && inputFile) {
|
|
422
|
+
return {
|
|
423
|
+
content: [
|
|
424
|
+
{
|
|
425
|
+
type: 'text',
|
|
426
|
+
text: 'Error: Cannot provide both descriptions and inputFile. Choose one method.',
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
isError: true,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
// Assigned by every branch below; an initializer would be dead on all of
|
|
433
|
+
// them.
|
|
434
|
+
let toolsToCache;
|
|
435
|
+
if (inputFile) {
|
|
436
|
+
// Read from file
|
|
437
|
+
try {
|
|
438
|
+
const filePath = resolve(inputFile);
|
|
439
|
+
const fileContent = readFileSync(filePath, 'utf-8');
|
|
440
|
+
toolsToCache = JSON.parse(fileContent);
|
|
441
|
+
if (!Array.isArray(toolsToCache)) {
|
|
442
|
+
return {
|
|
443
|
+
content: [
|
|
444
|
+
{
|
|
445
|
+
type: 'text',
|
|
446
|
+
text: 'Error: File must contain a JSON array of tools.',
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
isError: true,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
logger.info({ filePath, count: toolsToCache.length }, 'Read tools from file');
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
logger.error({ inputFile, error }, 'Failed to read tools from file');
|
|
456
|
+
return {
|
|
457
|
+
content: [
|
|
458
|
+
{
|
|
459
|
+
type: 'text',
|
|
460
|
+
text: `Error reading tools from file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
461
|
+
},
|
|
462
|
+
],
|
|
463
|
+
isError: true,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
else if (descriptions) {
|
|
468
|
+
toolsToCache = descriptions;
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
return {
|
|
472
|
+
content: [
|
|
473
|
+
{
|
|
474
|
+
type: 'text',
|
|
475
|
+
text: 'Error: Must provide either descriptions array or inputFile path.',
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
isError: true,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
if (toolsToCache.length > 100) {
|
|
482
|
+
return {
|
|
483
|
+
content: [
|
|
484
|
+
{
|
|
485
|
+
type: 'text',
|
|
486
|
+
text: `Error: Cannot cache more than 100 tools at once. Received ${toolsToCache.length} tools.`,
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
isError: true,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
// Snapshot every backend tool once. Looking the original description up per
|
|
493
|
+
// tool would issue one listTools round-trip per entry (up to 100 per call).
|
|
494
|
+
const backendTools = await fetchAllBackendTools();
|
|
495
|
+
const originalsByKey = new Map(backendTools.map((tool) => [`${tool.serverName}:${tool.toolName}`, tool.description]));
|
|
496
|
+
const coverageBefore = statsService.computeCoverage(backendTools);
|
|
497
|
+
let savedCount = 0;
|
|
498
|
+
for (const desc of toolsToCache) {
|
|
499
|
+
const { serverName, toolName, description: compressedDescription } = desc;
|
|
500
|
+
compressionCache.saveCompressed(serverName, toolName, compressedDescription, originalsByKey.get(`${serverName}:${toolName}`));
|
|
501
|
+
savedCount++;
|
|
502
|
+
}
|
|
503
|
+
// Recompute against the same snapshot to report before/after coverage
|
|
504
|
+
const coverageAfter = statsService.computeCoverage(backendTools);
|
|
505
|
+
const remainingTools = coverageAfter.uncompressedTools;
|
|
506
|
+
// Persist to disk
|
|
507
|
+
try {
|
|
508
|
+
await compressionCache.saveToDisk();
|
|
509
|
+
logger.info('Compression cache persisted to disk');
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
logger.error({ error }, 'Failed to persist cache to disk');
|
|
513
|
+
}
|
|
514
|
+
const sourceInfo = inputFile ? `from file: ${inputFile}` : 'from descriptions parameter';
|
|
515
|
+
return {
|
|
516
|
+
content: [
|
|
517
|
+
{
|
|
518
|
+
type: 'text',
|
|
519
|
+
text: `Cached ${savedCount} compressed tool descriptions successfully ${sourceInfo}.\n\nCoverage: ${coverageBefore.compressedTools}/${coverageBefore.totalTools} (${coverageBefore.coveragePercent}%) → ${coverageAfter.compressedTools}/${coverageAfter.totalTools} (${coverageAfter.coveragePercent}%)\nEstimated tokens saved: ~${coverageAfter.estimatedTokensSaved} (was ~${coverageBefore.estimatedTokensSaved})\n\n${remainingTools > 0 ? `Remaining tools to compress: ${remainingTools}\n\nCall mcp-compression-proxy__get_uncompressed_tools to continue with the next batch.` : 'All tools have been compressed! 🎉'}`,
|
|
520
|
+
},
|
|
521
|
+
],
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
if (name === 'mcp-compression-proxy__expand_tool') {
|
|
525
|
+
const { serverName, toolName } = args;
|
|
526
|
+
if (!currentSessionId) {
|
|
527
|
+
return {
|
|
528
|
+
content: [
|
|
529
|
+
{
|
|
530
|
+
type: 'text',
|
|
531
|
+
text: 'Error: No active session. Create a session first with mcp-compression-proxy__create_session.',
|
|
532
|
+
},
|
|
533
|
+
],
|
|
534
|
+
isError: true,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
if (!compressionCache.hasCompressed(serverName, toolName)) {
|
|
538
|
+
return {
|
|
539
|
+
content: [
|
|
540
|
+
{
|
|
541
|
+
type: 'text',
|
|
542
|
+
text: `Error: No compressed description found for ${serverName}:${toolName}`,
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
isError: true,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
sessionManager.expandTool(currentSessionId, serverName, toolName);
|
|
549
|
+
const original = compressionCache.getOriginalDescription(serverName, toolName);
|
|
550
|
+
const compressed = compressionCache.getCompressedDescription(serverName, toolName);
|
|
551
|
+
return {
|
|
552
|
+
content: [
|
|
553
|
+
{
|
|
554
|
+
type: 'text',
|
|
555
|
+
text: `Tool ${serverName}:${toolName} expanded in session ${currentSessionId}.\n\nOriginal: ${original}\nCompressed: ${compressed}`,
|
|
556
|
+
},
|
|
557
|
+
],
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
if (name === 'mcp-compression-proxy__collapse_tool') {
|
|
561
|
+
const { serverName, toolName } = args;
|
|
562
|
+
if (!currentSessionId) {
|
|
563
|
+
return {
|
|
564
|
+
content: [
|
|
565
|
+
{
|
|
566
|
+
type: 'text',
|
|
567
|
+
text: 'Error: No active session.',
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
isError: true,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
sessionManager.collapseTool(currentSessionId, serverName, toolName);
|
|
574
|
+
return {
|
|
575
|
+
content: [
|
|
576
|
+
{
|
|
577
|
+
type: 'text',
|
|
578
|
+
text: `Tool ${serverName}:${toolName} collapsed in session ${currentSessionId}.`,
|
|
579
|
+
},
|
|
580
|
+
],
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
if (name === 'mcp-compression-proxy__compress_via_sampling') {
|
|
584
|
+
const { limit = 25 } = args;
|
|
585
|
+
const actualLimit = Math.min(Math.max(limit, 1), 100);
|
|
586
|
+
if (!compressionSampler.isSupported()) {
|
|
587
|
+
return {
|
|
588
|
+
content: [
|
|
589
|
+
{
|
|
590
|
+
type: 'text',
|
|
591
|
+
text: 'Error: This client does not support MCP sampling, so the proxy cannot borrow its LLM.\n\nUse the manual flow instead: call mcp-compression-proxy__get_uncompressed_tools, compress the descriptions yourself, then post them back with mcp-compression-proxy__cache_compressed_tools.',
|
|
592
|
+
},
|
|
593
|
+
],
|
|
594
|
+
isError: true,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
const backendTools = await fetchAllBackendTools();
|
|
598
|
+
const coverageBefore = statsService.computeCoverage(backendTools);
|
|
599
|
+
const uncompressed = backendTools
|
|
600
|
+
.filter((tool) => !compressionCache.hasCompressed(tool.serverName, tool.toolName))
|
|
601
|
+
.slice(0, actualLimit);
|
|
602
|
+
if (uncompressed.length === 0) {
|
|
603
|
+
return {
|
|
604
|
+
content: [
|
|
605
|
+
{
|
|
606
|
+
type: 'text',
|
|
607
|
+
text: `Nothing to compress - all ${coverageBefore.totalTools} tools already have compressed descriptions.\n\n${statsService.formatCoverage(coverageBefore)}`,
|
|
608
|
+
},
|
|
609
|
+
],
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
const { descriptions, batchesAttempted, batchesFailed } = await compressionSampler.compress(uncompressed);
|
|
613
|
+
for (const entry of descriptions) {
|
|
614
|
+
const original = backendTools.find((tool) => tool.serverName === entry.serverName && tool.toolName === entry.toolName)?.description;
|
|
615
|
+
compressionCache.saveCompressed(entry.serverName, entry.toolName, entry.description, original);
|
|
616
|
+
}
|
|
617
|
+
if (descriptions.length > 0) {
|
|
618
|
+
try {
|
|
619
|
+
await compressionCache.saveToDisk();
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
logger.error({ error }, 'Failed to persist sampled compressions to disk');
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const coverageAfter = statsService.computeCoverage(backendTools);
|
|
626
|
+
const failureNote = batchesFailed > 0
|
|
627
|
+
? `\n\n${batchesFailed} of ${batchesAttempted} sampling batches produced no usable result. Re-run to retry them, or fall back to the manual flow.`
|
|
628
|
+
: '';
|
|
629
|
+
return {
|
|
630
|
+
content: [
|
|
631
|
+
{
|
|
632
|
+
type: 'text',
|
|
633
|
+
text: `Compressed ${descriptions.length} of ${uncompressed.length} tools using this client's LLM.\n\nCoverage: ${coverageBefore.compressedTools}/${coverageBefore.totalTools} (${coverageBefore.coveragePercent}%) → ${coverageAfter.compressedTools}/${coverageAfter.totalTools} (${coverageAfter.coveragePercent}%)\nEstimated tokens saved: ~${coverageAfter.estimatedTokensSaved}${failureNote}\n\n${coverageAfter.uncompressedTools > 0
|
|
634
|
+
? `Remaining: ${coverageAfter.uncompressedTools}. Call this tool again for the next batch.`
|
|
635
|
+
: 'All tools have been compressed! 🎉'}`,
|
|
636
|
+
},
|
|
637
|
+
],
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
if (name === 'mcp-compression-proxy__stats') {
|
|
641
|
+
const { serverName, detailLevel } = args;
|
|
642
|
+
try {
|
|
643
|
+
const stats = await statsService.getStats({ serverName, detailLevel });
|
|
644
|
+
return {
|
|
645
|
+
content: [
|
|
646
|
+
{
|
|
647
|
+
type: 'text',
|
|
648
|
+
text: JSON.stringify(stats, null, 2),
|
|
649
|
+
},
|
|
650
|
+
],
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
logger.error({ error, serverName }, 'Failed to compute stats');
|
|
655
|
+
return {
|
|
656
|
+
content: [
|
|
657
|
+
{
|
|
658
|
+
type: 'text',
|
|
659
|
+
text: `Error generating stats: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
660
|
+
},
|
|
661
|
+
],
|
|
662
|
+
isError: true,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// Aggregated MCP tool call
|
|
667
|
+
// Tool name format: "serverName__toolName". Split on the first separator
|
|
668
|
+
// only - backend tools are free to have "__" in their own names.
|
|
669
|
+
const separatorIndex = name.indexOf('__');
|
|
670
|
+
if (separatorIndex <= 0 || separatorIndex + 2 >= name.length) {
|
|
671
|
+
return {
|
|
672
|
+
content: [
|
|
673
|
+
{
|
|
674
|
+
type: 'text',
|
|
675
|
+
text: `Error: Invalid tool name format. Expected "serverName__toolName", got "${name}"`,
|
|
676
|
+
},
|
|
677
|
+
],
|
|
678
|
+
isError: true,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
const serverName = name.slice(0, separatorIndex);
|
|
682
|
+
const toolName = name.slice(separatorIndex + 2);
|
|
683
|
+
const client = clientManager.getClient(serverName);
|
|
684
|
+
if (!client) {
|
|
685
|
+
return {
|
|
686
|
+
content: [
|
|
687
|
+
{
|
|
688
|
+
type: 'text',
|
|
689
|
+
text: `Error: Server '${serverName}' not found or not connected`,
|
|
690
|
+
},
|
|
691
|
+
],
|
|
692
|
+
isError: true,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
try {
|
|
696
|
+
const result = await client.callTool({
|
|
697
|
+
name: toolName,
|
|
698
|
+
arguments: args || {},
|
|
699
|
+
});
|
|
700
|
+
return result;
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
logger.error({ serverName, toolName, error }, 'Tool call failed');
|
|
704
|
+
return {
|
|
705
|
+
content: [
|
|
706
|
+
{
|
|
707
|
+
type: 'text',
|
|
708
|
+
text: `Error calling tool: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
709
|
+
},
|
|
710
|
+
],
|
|
711
|
+
isError: true,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
/**
|
|
716
|
+
* Shut down backend servers and exit.
|
|
717
|
+
*
|
|
718
|
+
* Without this the proxy leaves every spawned backend MCP server running when
|
|
719
|
+
* its own client goes away, leaking a process tree per client restart.
|
|
720
|
+
*/
|
|
721
|
+
let shuttingDown = false;
|
|
722
|
+
async function shutdown(reason, exitCode = 0) {
|
|
723
|
+
if (shuttingDown)
|
|
724
|
+
return;
|
|
725
|
+
shuttingDown = true;
|
|
726
|
+
logger.info({ reason }, 'Shutting down');
|
|
727
|
+
try {
|
|
728
|
+
await clientManager.disconnectAll();
|
|
729
|
+
}
|
|
730
|
+
catch (error) {
|
|
731
|
+
logger.error({ error }, 'Error while disconnecting backend servers');
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
await server.close();
|
|
735
|
+
}
|
|
736
|
+
catch (error) {
|
|
737
|
+
logger.debug({ error }, 'Error while closing server transport');
|
|
738
|
+
}
|
|
739
|
+
sessionManager.destroy();
|
|
740
|
+
process.exit(exitCode);
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Parse command-line arguments
|
|
744
|
+
*/
|
|
745
|
+
function parseArgs() {
|
|
746
|
+
const args = process.argv.slice(2);
|
|
747
|
+
return {
|
|
748
|
+
clearCache: args.includes('--clear-cache'),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Start the server
|
|
753
|
+
*/
|
|
754
|
+
async function main() {
|
|
755
|
+
logger.info('Starting MCP Compression Proxy Server');
|
|
756
|
+
// Parse command-line arguments
|
|
757
|
+
const { clearCache } = parseArgs();
|
|
758
|
+
// Handle --clear-cache flag
|
|
759
|
+
if (clearCache) {
|
|
760
|
+
logger.info('Clearing compression cache...');
|
|
761
|
+
await compressionCache.clearAll();
|
|
762
|
+
logger.info('Cache cleared successfully');
|
|
763
|
+
process.exit(0);
|
|
764
|
+
}
|
|
765
|
+
// Load cached compressions from disk
|
|
766
|
+
try {
|
|
767
|
+
await compressionCache.loadFromDisk();
|
|
768
|
+
}
|
|
769
|
+
catch (error) {
|
|
770
|
+
logger.warn({ error }, 'Failed to load cache, continuing with empty cache');
|
|
771
|
+
}
|
|
772
|
+
// Load configuration from JSON files
|
|
773
|
+
const config = loadJSONServersCached();
|
|
774
|
+
// Initialize backend MCP servers BEFORE connecting to Q CLI
|
|
775
|
+
// This ensures all tools are available when the MCP client queries us
|
|
776
|
+
if (!config) {
|
|
777
|
+
logger.warn('No valid configuration found. Server will start with no backend MCP servers. Please create a servers.json file to add MCP servers.');
|
|
778
|
+
// Continue with empty configuration - server will only provide management tools
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
// Configure noCompress patterns and uncompressed-tool fallback
|
|
782
|
+
compressionCache.setNoCompressPatterns(config.noCompressPatterns);
|
|
783
|
+
compressionCache.setFallbackBehavior(config.compressionFallbackBehavior ?? 'original');
|
|
784
|
+
// Initialize MCP clients (only enabled servers)
|
|
785
|
+
const enabledServers = config.servers.filter(server => {
|
|
786
|
+
// Server is enabled if enabled field is not explicitly false
|
|
787
|
+
return server.enabled !== false;
|
|
788
|
+
});
|
|
789
|
+
logger.info({
|
|
790
|
+
total: config.servers.length,
|
|
791
|
+
enabled: enabledServers.length,
|
|
792
|
+
servers: enabledServers.map(s => s.name)
|
|
793
|
+
}, 'Initializing backend MCP servers with timeout protection');
|
|
794
|
+
// Wait for all servers to initialize or timeout before reporting ready
|
|
795
|
+
try {
|
|
796
|
+
await clientManager.initializeServers(enabledServers, config.defaultTimeout, config.inheritEnv);
|
|
797
|
+
logger.info('Backend MCP servers initialization complete');
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
logger.error({ error }, 'Error during backend server initialization');
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// Now connect to the MCP client - all backend servers are ready (or timed out)
|
|
804
|
+
const transport = new StdioServerTransport();
|
|
805
|
+
// When the client disconnects, take the backend servers down with us.
|
|
806
|
+
server.onclose = () => {
|
|
807
|
+
void shutdown('client disconnected');
|
|
808
|
+
};
|
|
809
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
810
|
+
process.on(signal, () => {
|
|
811
|
+
void shutdown(signal);
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
await server.connect(transport);
|
|
815
|
+
logger.info('MCP Compression Proxy Server ready and connected to stdio');
|
|
816
|
+
}
|
|
817
|
+
main().catch((error) => {
|
|
818
|
+
logger.error({ error }, 'Server failed to start');
|
|
819
|
+
process.exit(1);
|
|
820
|
+
});
|
|
821
|
+
//# sourceMappingURL=index.js.map
|