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,111 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ /**
5
+ * Service for persisting compressed tool descriptions to disk
6
+ */
7
+ export class CompressionPersistence {
8
+ logger;
9
+ cacheDir;
10
+ cacheFile;
11
+ VERSION = 1;
12
+ constructor(logger, cacheDir) {
13
+ this.logger = logger;
14
+ this.cacheDir = cacheDir || path.join(os.homedir(), '.mcp-compression-proxy');
15
+ this.cacheFile = path.join(this.cacheDir, 'cache.json');
16
+ }
17
+ /**
18
+ * Load cached compressions from disk
19
+ */
20
+ async load() {
21
+ const cache = new Map();
22
+ try {
23
+ // Check if cache file exists
24
+ await fs.access(this.cacheFile);
25
+ // Read and parse cache file
26
+ const data = await fs.readFile(this.cacheFile, 'utf-8');
27
+ const persisted = JSON.parse(data);
28
+ // Validate version
29
+ if (persisted.version !== this.VERSION) {
30
+ this.logger.warn({ fileVersion: persisted.version, currentVersion: this.VERSION }, 'Cache version mismatch, ignoring cached data');
31
+ return cache;
32
+ }
33
+ // Load compressions into map
34
+ for (const comp of persisted.compressions) {
35
+ const key = `${comp.serverName}:${comp.toolName}`;
36
+ cache.set(key, {
37
+ original: comp.originalDescription,
38
+ compressed: comp.compressedDescription,
39
+ compressedAt: comp.compressedAt,
40
+ });
41
+ }
42
+ this.logger.info({ count: cache.size, file: this.cacheFile }, 'Loaded compression cache from disk');
43
+ }
44
+ catch (error) {
45
+ if (error.code === 'ENOENT') {
46
+ this.logger.debug('No cache file found, starting with empty cache');
47
+ }
48
+ else {
49
+ this.logger.error({ error, file: this.cacheFile }, 'Failed to load cache from disk');
50
+ }
51
+ }
52
+ return cache;
53
+ }
54
+ /**
55
+ * Save compressions to disk
56
+ */
57
+ async save(cache) {
58
+ try {
59
+ // Ensure cache directory exists
60
+ await fs.mkdir(this.cacheDir, { recursive: true });
61
+ // Convert cache map to persisted format
62
+ const compressions = Array.from(cache.entries()).map(([key, value]) => {
63
+ const [serverName, toolName] = key.split(':');
64
+ return {
65
+ serverName,
66
+ toolName,
67
+ originalDescription: value.original,
68
+ compressedDescription: value.compressed,
69
+ compressedAt: value.compressedAt,
70
+ };
71
+ });
72
+ const persisted = {
73
+ version: this.VERSION,
74
+ lastUpdated: new Date().toISOString(),
75
+ compressions,
76
+ };
77
+ // Write to file with pretty formatting for debuggability
78
+ await fs.writeFile(this.cacheFile, JSON.stringify(persisted, null, 2), 'utf-8');
79
+ this.logger.info({ count: compressions.length, file: this.cacheFile }, 'Saved compression cache to disk');
80
+ }
81
+ catch (error) {
82
+ this.logger.error({ error, file: this.cacheFile }, 'Failed to save cache to disk');
83
+ throw error;
84
+ }
85
+ }
86
+ /**
87
+ * Clear the cache file from disk
88
+ */
89
+ async clear() {
90
+ try {
91
+ await fs.unlink(this.cacheFile);
92
+ this.logger.info({ file: this.cacheFile }, 'Cleared cache file from disk');
93
+ }
94
+ catch (error) {
95
+ if (error.code === 'ENOENT') {
96
+ this.logger.debug('Cache file does not exist, nothing to clear');
97
+ }
98
+ else {
99
+ this.logger.error({ error, file: this.cacheFile }, 'Failed to clear cache file');
100
+ throw error;
101
+ }
102
+ }
103
+ }
104
+ /**
105
+ * Get the cache file path (useful for debugging)
106
+ */
107
+ getCacheFilePath() {
108
+ return this.cacheFile;
109
+ }
110
+ }
111
+ //# sourceMappingURL=compression-persistence.js.map
@@ -0,0 +1,89 @@
1
+ import type { Logger } from 'pino';
2
+ import type { ObservedTool } from './stats-service.js';
3
+ /** A compressed description ready to be cached. */
4
+ export type SampledDescription = {
5
+ serverName: string;
6
+ toolName: string;
7
+ description: string;
8
+ };
9
+ /** Minimal shape of the SDK's createMessage result that we depend on. */
10
+ export type SamplingResult = {
11
+ content?: {
12
+ type?: string;
13
+ text?: string;
14
+ } | Array<{
15
+ type?: string;
16
+ text?: string;
17
+ }>;
18
+ };
19
+ /** The subset of the MCP server surface this needs, so it can be tested. */
20
+ export type SamplingHost = {
21
+ /** Client capabilities from the initialize handshake, if connected. */
22
+ getClientCapabilities(): {
23
+ sampling?: unknown;
24
+ } | undefined;
25
+ /** Ask the host LLM to complete a prompt. */
26
+ createMessage(params: {
27
+ messages: Array<{
28
+ role: 'user';
29
+ content: {
30
+ type: 'text';
31
+ text: string;
32
+ };
33
+ }>;
34
+ maxTokens: number;
35
+ systemPrompt?: string;
36
+ }): Promise<SamplingResult>;
37
+ };
38
+ /**
39
+ * Compresses tool descriptions using the *host's* LLM via `sampling/createMessage`.
40
+ *
41
+ * This makes compression free and zero-config on clients that support
42
+ * sampling: no separate API key, no second model to configure. Clients that
43
+ * do not advertise the capability keep the existing agent-driven flow, where
44
+ * the caller compresses descriptions itself and posts them back.
45
+ */
46
+ export declare class CompressionSampler {
47
+ private logger;
48
+ private host;
49
+ private batchSize;
50
+ constructor(logger: Logger, host: SamplingHost, batchSize?: number);
51
+ /**
52
+ * Whether the connected client advertised the `sampling` capability.
53
+ *
54
+ * Cursor supports it; Claude Desktop and Cline did not at the time of
55
+ * writing, so this must never be assumed.
56
+ */
57
+ isSupported(): boolean;
58
+ /** Split tools into request-sized batches. */
59
+ private batch;
60
+ /**
61
+ * Pull the text out of a sampling result, which the SDK may return as a
62
+ * single content block or an array of them.
63
+ */
64
+ private extractText;
65
+ /**
66
+ * Parse the model's reply into descriptions.
67
+ *
68
+ * Models wrap JSON in prose or a code fence often enough that requiring a
69
+ * clean array would fail routinely, so the outermost array is extracted
70
+ * before parsing. Entries that are malformed or name a tool that was not
71
+ * requested are dropped rather than poisoning the cache.
72
+ */
73
+ private parse;
74
+ /** Build the user prompt for one batch. */
75
+ private buildPrompt;
76
+ /**
77
+ * Compress the given tools via the host LLM.
78
+ *
79
+ * Batches are sent sequentially: each one is a round-trip through the host,
80
+ * which may prompt a human to approve it, so firing them in parallel would
81
+ * bury the user in approval dialogs.
82
+ */
83
+ compress(tools: ObservedTool[]): Promise<{
84
+ descriptions: SampledDescription[];
85
+ batchesAttempted: number;
86
+ batchesFailed: number;
87
+ }>;
88
+ }
89
+ //# sourceMappingURL=compression-sampler.d.ts.map
@@ -0,0 +1,171 @@
1
+ const SYSTEM_PROMPT = [
2
+ 'You compress MCP tool descriptions to reduce context consumption.',
3
+ 'For each tool you are given, write a much shorter description that still states',
4
+ 'what the tool does and when to use it, so a model can choose it correctly.',
5
+ 'Keep any detail that distinguishes the tool from a similar one. Drop examples,',
6
+ 'parameter lists, and restatements of the schema.',
7
+ 'Reply with ONLY a JSON array, no prose and no code fence, shaped like:',
8
+ '[{"serverName":"...","toolName":"...","description":"..."}]',
9
+ ].join(' ');
10
+ /** Default tools per sampling request - large enough to be efficient, small
11
+ * enough that the reply fits comfortably in maxTokens. */
12
+ const DEFAULT_BATCH_SIZE = 10;
13
+ const TOKENS_PER_TOOL = 120;
14
+ /**
15
+ * Compresses tool descriptions using the *host's* LLM via `sampling/createMessage`.
16
+ *
17
+ * This makes compression free and zero-config on clients that support
18
+ * sampling: no separate API key, no second model to configure. Clients that
19
+ * do not advertise the capability keep the existing agent-driven flow, where
20
+ * the caller compresses descriptions itself and posts them back.
21
+ */
22
+ export class CompressionSampler {
23
+ logger;
24
+ host;
25
+ batchSize;
26
+ constructor(logger, host, batchSize = DEFAULT_BATCH_SIZE) {
27
+ this.logger = logger;
28
+ this.host = host;
29
+ this.batchSize = Math.max(1, batchSize);
30
+ }
31
+ /**
32
+ * Whether the connected client advertised the `sampling` capability.
33
+ *
34
+ * Cursor supports it; Claude Desktop and Cline did not at the time of
35
+ * writing, so this must never be assumed.
36
+ */
37
+ isSupported() {
38
+ try {
39
+ return this.host.getClientCapabilities()?.sampling !== undefined;
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ /** Split tools into request-sized batches. */
46
+ batch(tools) {
47
+ const batches = [];
48
+ for (let i = 0; i < tools.length; i += this.batchSize) {
49
+ batches.push(tools.slice(i, i + this.batchSize));
50
+ }
51
+ return batches;
52
+ }
53
+ /**
54
+ * Pull the text out of a sampling result, which the SDK may return as a
55
+ * single content block or an array of them.
56
+ */
57
+ extractText(result) {
58
+ const content = result?.content;
59
+ if (!content)
60
+ return '';
61
+ if (Array.isArray(content)) {
62
+ return content
63
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
64
+ .map((part) => part.text)
65
+ .join('');
66
+ }
67
+ return typeof content.text === 'string' ? content.text : '';
68
+ }
69
+ /**
70
+ * Parse the model's reply into descriptions.
71
+ *
72
+ * Models wrap JSON in prose or a code fence often enough that requiring a
73
+ * clean array would fail routinely, so the outermost array is extracted
74
+ * before parsing. Entries that are malformed or name a tool that was not
75
+ * requested are dropped rather than poisoning the cache.
76
+ */
77
+ parse(text, requested) {
78
+ const start = text.indexOf('[');
79
+ const end = text.lastIndexOf(']');
80
+ if (start === -1 || end === -1 || end < start) {
81
+ this.logger.warn({ reply: text.slice(0, 200) }, 'Sampling reply contained no JSON array');
82
+ return [];
83
+ }
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(text.slice(start, end + 1));
87
+ }
88
+ catch (error) {
89
+ this.logger.warn({ error: error instanceof Error ? error.message : 'Unknown error' }, 'Sampling reply was not valid JSON');
90
+ return [];
91
+ }
92
+ if (!Array.isArray(parsed))
93
+ return [];
94
+ const allowed = new Set(requested.map((t) => `${t.serverName}:${t.toolName}`));
95
+ const results = [];
96
+ for (const entry of parsed) {
97
+ if (!entry || typeof entry !== 'object')
98
+ continue;
99
+ const { serverName, toolName, description } = entry;
100
+ if (typeof serverName !== 'string' ||
101
+ typeof toolName !== 'string' ||
102
+ typeof description !== 'string' ||
103
+ description.trim() === '') {
104
+ continue;
105
+ }
106
+ // Only accept tools we actually asked about - a hallucinated name would
107
+ // otherwise be cached and shown to the model later.
108
+ if (!allowed.has(`${serverName}:${toolName}`)) {
109
+ this.logger.warn({ serverName, toolName }, 'Sampling returned an unrequested tool');
110
+ continue;
111
+ }
112
+ results.push({ serverName, toolName, description: description.trim() });
113
+ }
114
+ return results;
115
+ }
116
+ /** Build the user prompt for one batch. */
117
+ buildPrompt(tools) {
118
+ const payload = tools.map((tool) => ({
119
+ serverName: tool.serverName,
120
+ toolName: tool.toolName,
121
+ description: tool.description ?? '',
122
+ }));
123
+ return `Compress these ${tools.length} MCP tool descriptions:\n\n${JSON.stringify(payload, null, 2)}`;
124
+ }
125
+ /**
126
+ * Compress the given tools via the host LLM.
127
+ *
128
+ * Batches are sent sequentially: each one is a round-trip through the host,
129
+ * which may prompt a human to approve it, so firing them in parallel would
130
+ * bury the user in approval dialogs.
131
+ */
132
+ async compress(tools) {
133
+ if (tools.length === 0) {
134
+ return { descriptions: [], batchesAttempted: 0, batchesFailed: 0 };
135
+ }
136
+ const batches = this.batch(tools);
137
+ const descriptions = [];
138
+ let batchesFailed = 0;
139
+ for (const [index, batchTools] of batches.entries()) {
140
+ try {
141
+ this.logger.debug({ batch: index + 1, of: batches.length, tools: batchTools.length }, 'Requesting compression from host LLM');
142
+ const result = await this.host.createMessage({
143
+ messages: [
144
+ {
145
+ role: 'user',
146
+ content: { type: 'text', text: this.buildPrompt(batchTools) },
147
+ },
148
+ ],
149
+ maxTokens: batchTools.length * TOKENS_PER_TOOL,
150
+ systemPrompt: SYSTEM_PROMPT,
151
+ });
152
+ const batchDescriptions = this.parse(this.extractText(result), batchTools);
153
+ if (batchDescriptions.length === 0) {
154
+ batchesFailed += 1;
155
+ }
156
+ descriptions.push(...batchDescriptions);
157
+ }
158
+ catch (error) {
159
+ // A host may reject the request outright - the user can decline a
160
+ // sampling prompt. Keep whatever earlier batches produced.
161
+ batchesFailed += 1;
162
+ this.logger.warn({
163
+ batch: index + 1,
164
+ error: error instanceof Error ? error.message : 'Unknown error',
165
+ }, 'Sampling request failed');
166
+ }
167
+ }
168
+ return { descriptions, batchesAttempted: batches.length, batchesFailed };
169
+ }
170
+ }
171
+ //# sourceMappingURL=compression-sampler.js.map
@@ -0,0 +1,64 @@
1
+ import type { SessionInfo } from '../types/compression.js';
2
+ import type { Logger } from 'pino';
3
+ /**
4
+ * Manages sessions for per-client tool expansion state
5
+ */
6
+ export declare class SessionManager {
7
+ private sessions;
8
+ private logger;
9
+ private readonly SESSION_TIMEOUT_MS;
10
+ private cleanupTimer;
11
+ constructor(logger: Logger);
12
+ /**
13
+ * Create a new session
14
+ */
15
+ createSession(): string;
16
+ /**
17
+ * Get session info
18
+ */
19
+ getSession(sessionId: string): SessionInfo | undefined;
20
+ /**
21
+ * Check if session exists
22
+ */
23
+ hasSession(sessionId: string): boolean;
24
+ /**
25
+ * Delete a session
26
+ */
27
+ deleteSession(sessionId: string): boolean;
28
+ /**
29
+ * Add expanded tool to session
30
+ */
31
+ expandTool(sessionId: string, serverName: string, toolName: string): boolean;
32
+ /**
33
+ * Remove expanded tool from session
34
+ */
35
+ collapseTool(sessionId: string, serverName: string, toolName: string): boolean;
36
+ /**
37
+ * Check if a tool is expanded in a session
38
+ */
39
+ isToolExpanded(sessionId: string | undefined, serverName: string, toolName: string): boolean;
40
+ /**
41
+ * Get all sessions
42
+ */
43
+ getAllSessions(): SessionInfo[];
44
+ /**
45
+ * Get session statistics
46
+ */
47
+ getSessionStats(sessionId: string): {
48
+ expandedToolsCount: number;
49
+ expandedTools: string[];
50
+ } | null;
51
+ /**
52
+ * Cleanup expired sessions
53
+ */
54
+ private cleanupExpiredSessions;
55
+ /**
56
+ * Clear all sessions
57
+ */
58
+ clearAllSessions(): void;
59
+ /**
60
+ * Destroy the session manager and cleanup resources
61
+ */
62
+ destroy(): void;
63
+ }
64
+ //# sourceMappingURL=session-manager.d.ts.map
@@ -0,0 +1,160 @@
1
+ import { randomUUID } from 'crypto';
2
+ /**
3
+ * Manages sessions for per-client tool expansion state
4
+ */
5
+ export class SessionManager {
6
+ sessions = new Map();
7
+ logger;
8
+ SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
9
+ cleanupTimer;
10
+ constructor(logger) {
11
+ this.logger = logger;
12
+ // Cleanup expired sessions every 5 minutes. unref'd because housekeeping
13
+ // should never be the reason the process stays alive - without it every
14
+ // SessionManager holds the event loop open until destroy() is called.
15
+ this.cleanupTimer = setInterval(() => this.cleanupExpiredSessions(), 5 * 60 * 1000);
16
+ this.cleanupTimer.unref?.();
17
+ }
18
+ /**
19
+ * Create a new session
20
+ */
21
+ createSession() {
22
+ const sessionId = randomUUID();
23
+ const now = new Date().toISOString();
24
+ this.sessions.set(sessionId, {
25
+ sessionId,
26
+ createdAt: now,
27
+ lastAccessedAt: now,
28
+ expandedTools: [],
29
+ });
30
+ this.logger.info({ sessionId }, 'Created new session');
31
+ return sessionId;
32
+ }
33
+ /**
34
+ * Get session info
35
+ */
36
+ getSession(sessionId) {
37
+ const session = this.sessions.get(sessionId);
38
+ if (session) {
39
+ // Update last accessed time
40
+ session.lastAccessedAt = new Date().toISOString();
41
+ }
42
+ return session;
43
+ }
44
+ /**
45
+ * Check if session exists
46
+ */
47
+ hasSession(sessionId) {
48
+ return this.sessions.has(sessionId);
49
+ }
50
+ /**
51
+ * Delete a session
52
+ */
53
+ deleteSession(sessionId) {
54
+ const existed = this.sessions.delete(sessionId);
55
+ if (existed) {
56
+ this.logger.info({ sessionId }, 'Deleted session');
57
+ }
58
+ return existed;
59
+ }
60
+ /**
61
+ * Add expanded tool to session
62
+ */
63
+ expandTool(sessionId, serverName, toolName) {
64
+ const session = this.sessions.get(sessionId);
65
+ if (!session) {
66
+ this.logger.warn({ sessionId }, 'Session not found for expand');
67
+ return false;
68
+ }
69
+ const toolKey = `${serverName}:${toolName}`;
70
+ if (!session.expandedTools.includes(toolKey)) {
71
+ session.expandedTools.push(toolKey);
72
+ session.lastAccessedAt = new Date().toISOString();
73
+ this.logger.debug({ sessionId, serverName, toolName }, 'Expanded tool in session');
74
+ }
75
+ return true;
76
+ }
77
+ /**
78
+ * Remove expanded tool from session
79
+ */
80
+ collapseTool(sessionId, serverName, toolName) {
81
+ const session = this.sessions.get(sessionId);
82
+ if (!session) {
83
+ this.logger.warn({ sessionId }, 'Session not found for collapse');
84
+ return false;
85
+ }
86
+ const toolKey = `${serverName}:${toolName}`;
87
+ const index = session.expandedTools.indexOf(toolKey);
88
+ if (index !== -1) {
89
+ session.expandedTools.splice(index, 1);
90
+ session.lastAccessedAt = new Date().toISOString();
91
+ this.logger.debug({ sessionId, serverName, toolName }, 'Collapsed tool in session');
92
+ return true;
93
+ }
94
+ return false;
95
+ }
96
+ /**
97
+ * Check if a tool is expanded in a session
98
+ */
99
+ isToolExpanded(sessionId, serverName, toolName) {
100
+ if (!sessionId)
101
+ return false;
102
+ const session = this.sessions.get(sessionId);
103
+ if (!session)
104
+ return false;
105
+ const toolKey = `${serverName}:${toolName}`;
106
+ return session.expandedTools.includes(toolKey);
107
+ }
108
+ /**
109
+ * Get all sessions
110
+ */
111
+ getAllSessions() {
112
+ return Array.from(this.sessions.values());
113
+ }
114
+ /**
115
+ * Get session statistics
116
+ */
117
+ getSessionStats(sessionId) {
118
+ const session = this.sessions.get(sessionId);
119
+ if (!session)
120
+ return null;
121
+ return {
122
+ expandedToolsCount: session.expandedTools.length,
123
+ expandedTools: session.expandedTools,
124
+ };
125
+ }
126
+ /**
127
+ * Cleanup expired sessions
128
+ */
129
+ cleanupExpiredSessions() {
130
+ const now = Date.now();
131
+ let cleaned = 0;
132
+ for (const [sessionId, session] of this.sessions.entries()) {
133
+ const lastAccessed = new Date(session.lastAccessedAt).getTime();
134
+ const age = now - lastAccessed;
135
+ if (age > this.SESSION_TIMEOUT_MS) {
136
+ this.sessions.delete(sessionId);
137
+ cleaned++;
138
+ }
139
+ }
140
+ if (cleaned > 0) {
141
+ this.logger.info({ cleaned }, 'Cleaned up expired sessions');
142
+ }
143
+ }
144
+ /**
145
+ * Clear all sessions
146
+ */
147
+ clearAllSessions() {
148
+ const count = this.sessions.size;
149
+ this.sessions.clear();
150
+ this.logger.info({ count }, 'Cleared all sessions');
151
+ }
152
+ /**
153
+ * Destroy the session manager and cleanup resources
154
+ */
155
+ destroy() {
156
+ clearInterval(this.cleanupTimer);
157
+ this.clearAllSessions();
158
+ }
159
+ }
160
+ //# sourceMappingURL=session-manager.js.map
@@ -0,0 +1,101 @@
1
+ import type { Logger } from 'pino';
2
+ import type { MCPClientManager } from '../mcp/client-manager.js';
3
+ import type { CompressionCache } from './compression-cache.js';
4
+ import type { SessionManager } from './session-manager.js';
5
+ import type { ConfigResult } from '../config/loader.js';
6
+ type DetailLevel = 'summary' | 'full';
7
+ type ServerToolStats = {
8
+ name: string;
9
+ connected: boolean;
10
+ error?: string;
11
+ toolsTotal: number;
12
+ toolsCompressed: number;
13
+ toolsUncompressed: number;
14
+ toolsExcluded: number;
15
+ coveragePercent: number;
16
+ originalChars: number;
17
+ compressedChars: number;
18
+ estimatedTokensSaved: number;
19
+ };
20
+ export type StatsPayload = {
21
+ summary: {
22
+ serversConfigured: number;
23
+ serversConnected: number;
24
+ serversWithErrors: number;
25
+ toolsTotal: number;
26
+ toolsCompressed: number;
27
+ toolsUncompressed: number;
28
+ coveragePercent: number;
29
+ originalChars: number;
30
+ compressedChars: number;
31
+ estimatedTokensSaved: number;
32
+ };
33
+ servers: ServerToolStats[];
34
+ compression: {
35
+ cacheEntries: number;
36
+ cacheFilePath?: string;
37
+ cacheSizeBytes: number;
38
+ missingOriginals: number;
39
+ latestCompressedAt?: string;
40
+ totalOriginalChars: number;
41
+ totalCompressedChars: number;
42
+ estimatedTokensSaved: number;
43
+ };
44
+ sessions: {
45
+ activeSessions: number;
46
+ expandedToolsTotal: number;
47
+ sessions?: Array<{
48
+ sessionId: string;
49
+ expandedToolsCount: number;
50
+ }>;
51
+ };
52
+ config: {
53
+ excludePatterns: string[];
54
+ noCompressPatterns: string[];
55
+ };
56
+ };
57
+ /** A tool as seen during aggregation, before its description is rewritten. */
58
+ export type ObservedTool = {
59
+ serverName: string;
60
+ toolName: string;
61
+ description?: string;
62
+ };
63
+ /** Compression coverage over a set of tools, computed without extra I/O. */
64
+ export type LiveCoverage = {
65
+ totalTools: number;
66
+ compressedTools: number;
67
+ uncompressedTools: number;
68
+ coveragePercent: number;
69
+ originalChars: number;
70
+ compressedChars: number;
71
+ estimatedTokensSaved: number;
72
+ latestCompressedAt?: string;
73
+ };
74
+ export declare class StatsService {
75
+ private logger;
76
+ private clientManager;
77
+ private compressionCache;
78
+ private sessionManager;
79
+ private configLoader;
80
+ constructor(logger: Logger, clientManager: MCPClientManager, compressionCache: CompressionCache, sessionManager: SessionManager, configLoader?: () => ConfigResult);
81
+ getStats(options?: {
82
+ detailLevel?: DetailLevel;
83
+ serverName?: string;
84
+ }): Promise<StatsPayload>;
85
+ /**
86
+ * Compute coverage over tools already fetched during aggregation.
87
+ *
88
+ * Unlike {@link getStats} this issues no `listTools` calls, so it is cheap
89
+ * enough to run on every `tools/list`.
90
+ */
91
+ computeCoverage(tools: ObservedTool[]): LiveCoverage;
92
+ /**
93
+ * One-line coverage summary suitable for appending to a tool description.
94
+ * Kept terse - it ships on every `tools/list`.
95
+ */
96
+ formatCoverage(coverage: LiveCoverage): string;
97
+ private coverage;
98
+ private calculateTokensSaved;
99
+ }
100
+ export {};
101
+ //# sourceMappingURL=stats-service.d.ts.map