mcp-compression-proxy 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ import { type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ import type { Logger } from 'pino';
3
+ import type { MCPClientManager } from './client-manager.js';
4
+ export declare function matchesAuthenticationFailure(value: unknown, patterns: string[]): boolean;
5
+ /**
6
+ * Execute a backend tool call and recover once from configured authentication
7
+ * failures. Every auth failure invalidates the exact connection generation.
8
+ * Automatic replay is restricted to explicitly configured read-only tools.
9
+ */
10
+ export declare function callToolWithAuthRecovery(manager: MCPClientManager, logger: Logger, serverName: string, toolName: string, args: Record<string, unknown>): Promise<CallToolResult>;
11
+ //# sourceMappingURL=tool-call-executor.d.ts.map
@@ -0,0 +1,94 @@
1
+ import { CallToolResultSchema, } from '@modelcontextprotocol/sdk/types.js';
2
+ import { matchesIgnorePattern } from '../config/loader.js';
3
+ function searchableText(value) {
4
+ if (value instanceof Error) {
5
+ return `${value.name}: ${value.message}`;
6
+ }
7
+ if (typeof value === 'string') {
8
+ return value;
9
+ }
10
+ try {
11
+ return JSON.stringify(value);
12
+ }
13
+ catch {
14
+ return String(value);
15
+ }
16
+ }
17
+ function isCallToolResult(value) {
18
+ return (typeof value === 'object' &&
19
+ value !== null &&
20
+ Array.isArray(value.content));
21
+ }
22
+ export function matchesAuthenticationFailure(value, patterns) {
23
+ if (patterns.length === 0)
24
+ return false;
25
+ const text = searchableText(value).toLowerCase();
26
+ return patterns.some((pattern) => {
27
+ const normalized = pattern.trim().toLowerCase();
28
+ return normalized.length > 0 && text.includes(normalized);
29
+ });
30
+ }
31
+ function isRetrySafe(serverName, toolName, patterns) {
32
+ return (matchesIgnorePattern(toolName, patterns) ||
33
+ matchesIgnorePattern(`${serverName}__${toolName}`, patterns));
34
+ }
35
+ async function attemptToolCall(manager, serverName, toolName, args, authErrorPatterns) {
36
+ let authFailure = false;
37
+ try {
38
+ const result = await manager.withClient(serverName, async ({ client, invalidate, markFailure }) => {
39
+ try {
40
+ const rawResult = await client.callTool({
41
+ name: toolName,
42
+ arguments: args,
43
+ }, CallToolResultSchema);
44
+ if (!isCallToolResult(rawResult)) {
45
+ throw new Error('Task-based MCP tool results are not supported by the proxy');
46
+ }
47
+ const callResult = rawResult;
48
+ if (matchesAuthenticationFailure(callResult, authErrorPatterns)) {
49
+ authFailure = true;
50
+ invalidate('auth-error');
51
+ }
52
+ else if (callResult.isError === true) {
53
+ markFailure(`Tool '${toolName}' reported an error`);
54
+ }
55
+ return callResult;
56
+ }
57
+ catch (error) {
58
+ if (matchesAuthenticationFailure(error, authErrorPatterns)) {
59
+ authFailure = true;
60
+ invalidate('auth-error');
61
+ }
62
+ throw error;
63
+ }
64
+ });
65
+ return { result, authFailure };
66
+ }
67
+ catch (error) {
68
+ return { error, authFailure };
69
+ }
70
+ }
71
+ /**
72
+ * Execute a backend tool call and recover once from configured authentication
73
+ * failures. Every auth failure invalidates the exact connection generation.
74
+ * Automatic replay is restricted to explicitly configured read-only tools.
75
+ */
76
+ export async function callToolWithAuthRecovery(manager, logger, serverName, toolName, args) {
77
+ const policy = manager.getAuthRecoveryPolicy(serverName);
78
+ const retrySafe = isRetrySafe(serverName, toolName, policy.authRetryTools);
79
+ const first = await attemptToolCall(manager, serverName, toolName, args, policy.authErrorPatterns);
80
+ if (!first.authFailure || !retrySafe) {
81
+ if (first.authFailure) {
82
+ logger.warn({ server: serverName, tool: toolName }, 'Authentication failure invalidated the MCP connection; tool was not replayed');
83
+ }
84
+ if ('error' in first)
85
+ throw first.error;
86
+ return first.result;
87
+ }
88
+ logger.warn({ server: serverName, tool: toolName }, 'Retrying read-only MCP tool after authentication recovery');
89
+ const second = await attemptToolCall(manager, serverName, toolName, args, policy.authErrorPatterns);
90
+ if ('error' in second)
91
+ throw second.error;
92
+ return second.result;
93
+ }
94
+ //# sourceMappingURL=tool-call-executor.js.map
@@ -65,6 +65,24 @@ export declare class CompressionCache {
65
65
  * Check if a tool has compressed description
66
66
  */
67
67
  hasCompressed(serverName: string, toolName: string): boolean;
68
+ /**
69
+ * Whether a cached compression was made from a description the backend no
70
+ * longer serves.
71
+ *
72
+ * Both sides must be present to say anything: entries cached before originals
73
+ * were recorded have no baseline (see `missingOriginals` in the metrics), and
74
+ * a backend that reports no description gives nothing to compare. Treating
75
+ * either as stale would put those tools into a recompression loop they could
76
+ * never exit.
77
+ */
78
+ isStale(serverName: string, toolName: string, liveOriginal?: string): boolean;
79
+ /**
80
+ * Drop one tool's cached compression. Returns whether there was one.
81
+ *
82
+ * Persisting is left to the caller so a batch of invalidations costs a single
83
+ * disk write.
84
+ */
85
+ invalidate(serverName: string, toolName: string): boolean;
68
86
  /**
69
87
  * Get original description if cached
70
88
  */
@@ -111,6 +111,38 @@ export class CompressionCache {
111
111
  const key = this.getKey(serverName, toolName);
112
112
  return !!this.cache[key]?.compressed;
113
113
  }
114
+ /**
115
+ * Whether a cached compression was made from a description the backend no
116
+ * longer serves.
117
+ *
118
+ * Both sides must be present to say anything: entries cached before originals
119
+ * were recorded have no baseline (see `missingOriginals` in the metrics), and
120
+ * a backend that reports no description gives nothing to compare. Treating
121
+ * either as stale would put those tools into a recompression loop they could
122
+ * never exit.
123
+ */
124
+ isStale(serverName, toolName, liveOriginal) {
125
+ const entry = this.cache[this.getKey(serverName, toolName)];
126
+ if (!entry?.compressed)
127
+ return false;
128
+ if (!entry.original || !liveOriginal)
129
+ return false;
130
+ return entry.original !== liveOriginal;
131
+ }
132
+ /**
133
+ * Drop one tool's cached compression. Returns whether there was one.
134
+ *
135
+ * Persisting is left to the caller so a batch of invalidations costs a single
136
+ * disk write.
137
+ */
138
+ invalidate(serverName, toolName) {
139
+ const key = this.getKey(serverName, toolName);
140
+ if (!this.cache[key])
141
+ return false;
142
+ delete this.cache[key];
143
+ this.logger.debug({ serverName, toolName }, 'Invalidated compressed description');
144
+ return true;
145
+ }
114
146
  /**
115
147
  * Get original description if cached
116
148
  */
@@ -102,6 +102,11 @@ export class SessionManager {
102
102
  const session = this.sessions.get(sessionId);
103
103
  if (!session)
104
104
  return false;
105
+ // Reading counts as use. This runs for every tool on every tools/list, so
106
+ // without it a session that is only ever read - the normal case, since
107
+ // expand/collapse are one-off calls - ages out mid-conversation and its
108
+ // expanded tools silently collapse back to compressed descriptions.
109
+ session.lastAccessedAt = new Date().toISOString();
105
110
  const toolKey = `${serverName}:${toolName}`;
106
111
  return session.expandedTools.includes(toolKey);
107
112
  }
@@ -1,5 +1,6 @@
1
1
  import type { Logger } from 'pino';
2
2
  import type { MCPClientManager } from '../mcp/client-manager.js';
3
+ import type { ConnectionLifecycleState } from '../types/index.js';
3
4
  import type { CompressionCache } from './compression-cache.js';
4
5
  import type { SessionManager } from './session-manager.js';
5
6
  import type { ConfigResult } from '../config/loader.js';
@@ -7,6 +8,7 @@ type DetailLevel = 'summary' | 'full';
7
8
  type ServerToolStats = {
8
9
  name: string;
9
10
  connected: boolean;
11
+ state?: ConnectionLifecycleState;
10
12
  error?: string;
11
13
  toolsTotal: number;
12
14
  toolsCompressed: number;
@@ -65,6 +67,8 @@ export type LiveCoverage = {
65
67
  totalTools: number;
66
68
  compressedTools: number;
67
69
  uncompressedTools: number;
70
+ /** Compressed from a description the backend has since changed. */
71
+ staleTools: number;
68
72
  coveragePercent: number;
69
73
  originalChars: number;
70
74
  compressedChars: number;
@@ -31,8 +31,6 @@ export class StatsService {
31
31
  const excludePatterns = config.excludePatterns || [];
32
32
  const noCompressPatterns = config.noCompressPatterns || [];
33
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
34
  const targetStatuses = serverFilter
37
35
  ? serverStatuses.filter((s) => s.name === serverFilter)
38
36
  : serverStatuses;
@@ -41,41 +39,8 @@ export class StatsService {
41
39
  }
42
40
  const serverStats = [];
43
41
  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
42
  try {
78
- const result = await client.listTools();
43
+ const result = await this.clientManager.withClient(status.name, async ({ client }) => client.listTools());
79
44
  const filtered = result.tools.filter((tool) => !matchesIgnorePattern(`${status.name}__${tool.name}`, excludePatterns));
80
45
  const excludedCount = result.tools.length - filtered.length;
81
46
  let toolsCompressed = 0;
@@ -100,6 +65,7 @@ export class StatsService {
100
65
  serverStats.push({
101
66
  name: status.name,
102
67
  connected: true,
68
+ state: 'ready',
103
69
  toolsTotal,
104
70
  toolsCompressed,
105
71
  toolsUncompressed,
@@ -115,7 +81,9 @@ export class StatsService {
115
81
  this.logger.warn({ server: status.name, error: message }, 'Failed to list tools for stats');
116
82
  serverStats.push({
117
83
  name: status.name,
118
- connected: true,
84
+ connected: false,
85
+ state: this.clientManager.getServerStatuses()
86
+ .find((current) => current.name === status.name)?.state,
119
87
  error: message,
120
88
  toolsTotal: 0,
121
89
  toolsCompressed: 0,
@@ -137,8 +105,8 @@ export class StatsService {
137
105
  const payload = {
138
106
  summary: {
139
107
  serversConfigured: config.servers?.length || serverStatuses.length,
140
- serversConnected: serverStatuses.filter((s) => s.connected).length,
141
- serversWithErrors: serverStatuses.filter((s) => !s.connected || s.lastError).length,
108
+ serversConnected: serverStats.filter((s) => s.connected).length,
109
+ serversWithErrors: serverStats.filter((s) => !s.connected || s.error).length,
142
110
  toolsTotal: aggregateTotalTools,
143
111
  toolsCompressed: aggregateCompressed,
144
112
  toolsUncompressed: Math.max(aggregateTotalTools - aggregateCompressed, 0),
@@ -183,6 +151,7 @@ export class StatsService {
183
151
  */
184
152
  computeCoverage(tools) {
185
153
  let compressedTools = 0;
154
+ let staleTools = 0;
186
155
  let originalChars = 0;
187
156
  let compressedChars = 0;
188
157
  let latestCompressedAt;
@@ -194,6 +163,11 @@ export class StatsService {
194
163
  if (compressed !== undefined) {
195
164
  compressedTools += 1;
196
165
  compressedChars += compressed.length;
166
+ // The live description is already in hand here, so staleness costs no
167
+ // extra listTools call - preserve that when touching this loop.
168
+ if (this.compressionCache.isStale(tool.serverName, tool.toolName, tool.description)) {
169
+ staleTools += 1;
170
+ }
197
171
  }
198
172
  else {
199
173
  // Uncompressed tools still occupy their original description.
@@ -209,6 +183,7 @@ export class StatsService {
209
183
  totalTools: tools.length,
210
184
  compressedTools,
211
185
  uncompressedTools: Math.max(tools.length - compressedTools, 0),
186
+ staleTools,
212
187
  coveragePercent: this.coverage(compressedTools, tools.length),
213
188
  originalChars,
214
189
  compressedChars,
@@ -228,6 +203,10 @@ export class StatsService {
228
203
  `${coverage.compressedTools}/${coverage.totalTools} compressed (${coverage.coveragePercent}%)`,
229
204
  `${coverage.uncompressedTools} remaining`,
230
205
  ];
206
+ // Only when non-zero: this string ships on every tools/list.
207
+ if (coverage.staleTools > 0) {
208
+ parts.push(`${coverage.staleTools} stale`);
209
+ }
231
210
  if (coverage.estimatedTokensSaved > 0) {
232
211
  parts.push(`~${compact(coverage.estimatedTokensSaved)} tokens saved`);
233
212
  }
@@ -1,22 +1,63 @@
1
1
  import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
2
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
3
+ /**
4
+ * A backend server, either spawned locally (`command`) or reached over HTTP
5
+ * (`url`). The two are mutually exclusive, enforced by the config schema.
6
+ *
7
+ * Flat optionals rather than a discriminated union: `initializeServers`
8
+ * applies defaults by spreading (`{...server, timeout: ...}`), and TypeScript
9
+ * cannot keep a union narrowed across that.
10
+ */
3
11
  export interface MCPServerConfig {
4
12
  name: string;
5
- command: string;
13
+ command?: string;
6
14
  args?: string[];
7
15
  env?: Record<string, string>;
8
16
  /** Which of the proxy's env vars to pass through. Defaults to all. */
9
17
  inheritEnv?: boolean | string[];
18
+ /** Endpoint of a hosted MCP server. Mutually exclusive with `command`. */
19
+ url?: string;
20
+ /** Static headers sent with every request to `url`, e.g. Authorization. */
21
+ headers?: Record<string, string>;
10
22
  enabled?: boolean;
11
23
  timeout?: number;
24
+ /** Lazy recycle threshold. Reopen on the next use after this age. 0 disables. */
25
+ softMaxConnectionAgeSeconds?: number;
26
+ /** Absolute lifetime. Drain at this age and close after active calls finish. 0 disables. */
27
+ hardMaxConnectionAgeSeconds?: number;
28
+ /** Deprecated alias for softMaxConnectionAgeSeconds. */
29
+ maxConnectionAgeSeconds?: number;
30
+ /** Case-insensitive substrings that identify an authentication failure. */
31
+ authErrorPatterns?: string[];
32
+ /** Tool-name wildcard patterns that are safe to retry once after auth recovery. */
33
+ authRetryTools?: string[];
12
34
  }
13
35
  export interface MCPClientConnection {
14
36
  name: string;
15
37
  client: Client;
16
- transport: Transport;
38
+ /**
39
+ * Absent when the transport could not be built at all - a malformed `url`,
40
+ * say. Recording the failure still matters, so this cannot be required.
41
+ */
42
+ transport?: Transport;
17
43
  connected: boolean;
18
44
  lastError?: string;
45
+ /**
46
+ * The resolved config this connection was built from - defaults already
47
+ * merged in. A reconnect replays it verbatim instead of re-deriving the
48
+ * timeout and env policy, which would silently drift from the original.
49
+ */
50
+ config: MCPServerConfig;
51
+ }
52
+ export interface ConnectionLifecycleDefaults {
53
+ softMaxConnectionAgeSeconds?: number;
54
+ hardMaxConnectionAgeSeconds?: number;
55
+ /** Deprecated alias for softMaxConnectionAgeSeconds. */
56
+ maxConnectionAgeSeconds?: number;
57
+ authErrorPatterns?: string[];
58
+ authRetryTools?: string[];
19
59
  }
60
+ export type ConnectionLifecycleState = 'closed' | 'starting' | 'ready' | 'draining' | 'failed';
20
61
  export interface AggregatedToolsResponse {
21
62
  tools: Array<{
22
63
  name: string;
@@ -44,6 +85,20 @@ export interface ServerStatus {
44
85
  name: string;
45
86
  connected: boolean;
46
87
  lastError?: string;
88
+ state?: ConnectionLifecycleState;
89
+ activeCalls?: number;
90
+ drainingConnections?: number;
91
+ generation?: number;
92
+ connectedAt?: number;
93
+ lastUsedAt?: number;
94
+ lastAttemptAt?: number;
95
+ lastSuccessAt?: number;
96
+ connectionAgeSeconds?: number;
97
+ softMaxConnectionAgeSeconds?: number;
98
+ hardMaxConnectionAgeSeconds?: number;
99
+ recycleCount?: number;
100
+ authInvalidations?: number;
101
+ consecutiveFailures?: number;
47
102
  }
48
103
  export interface HealthResponse {
49
104
  status: 'healthy' | 'degraded' | 'unhealthy';
@@ -61,7 +116,7 @@ export interface ToolsQueryParams {
61
116
  pattern?: string;
62
117
  sessionId?: string;
63
118
  }
64
- export type IPCMethod = 'tools' | 'search' | 'info' | 'call' | 'stats' | 'daemon-status';
119
+ export type IPCMethod = 'tools' | 'search' | 'info' | 'call' | 'payload-read' | 'payload-find' | 'script' | 'stats' | 'daemon-status';
65
120
  export interface IPCRequest {
66
121
  id: string;
67
122
  method: IPCMethod;
@@ -83,6 +138,7 @@ export interface CLIConfig {
83
138
  export interface DaemonStatusResult {
84
139
  running: boolean;
85
140
  pid: number;
141
+ releaseId?: string;
86
142
  uptime: number;
87
143
  servers: ServerStatus[];
88
144
  /**
package/dist/version.d.ts CHANGED
@@ -7,5 +7,5 @@
7
7
  * constant never needs to be edited by hand.
8
8
  */
9
9
  export declare const SERVER_NAME = "mcp-compression-proxy";
10
- export declare const VERSION = "1.0.2";
10
+ export declare const VERSION = "1.1.0";
11
11
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -7,5 +7,5 @@
7
7
  * constant never needs to be edited by hand.
8
8
  */
9
9
  export const SERVER_NAME = 'mcp-compression-proxy';
10
- export const VERSION = '1.0.2';
10
+ export const VERSION = '1.1.0';
11
11
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-compression-proxy",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
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
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -29,6 +29,7 @@
29
29
  "cli:start": "node dist/cli/index.js daemon start",
30
30
  "cli:stop": "node dist/cli/index.js daemon stop",
31
31
  "sync-version": "node scripts/sync-version.mjs",
32
+ "check:release-notes": "node scripts/check-release-notes.mjs",
32
33
  "prepare": "husky || true",
33
34
  "prepublishOnly": "npm run build && npm test"
34
35
  },
@@ -71,7 +72,7 @@
71
72
  "url": "https://github.com/kdpa-llc/mcp-compression-proxy/issues"
72
73
  },
73
74
  "dependencies": {
74
- "@modelcontextprotocol/sdk": "^1.0.4",
75
+ "@modelcontextprotocol/sdk": "^1.30.0",
75
76
  "ajv": "^8.17.1",
76
77
  "pino": "^10.3.1",
77
78
  "pino-pretty": "^13.0.0"
@@ -81,10 +82,10 @@
81
82
  "@commitlint/config-conventional": "^21.2.2",
82
83
  "@eslint/js": "^10.0.1",
83
84
  "@jest/globals": "^30.4.1",
84
- "@semantic-release/changelog": "^6.0.3",
85
+ "@semantic-release/changelog": "^7.0.0",
85
86
  "@semantic-release/commit-analyzer": "^13.0.1",
86
87
  "@semantic-release/exec": "^7.1.0",
87
- "@semantic-release/git": "^10.0.1",
88
+ "@semantic-release/git": "^11.0.1",
88
89
  "@semantic-release/github": "^12.0.2",
89
90
  "@semantic-release/npm": "^13.1.2",
90
91
  "@semantic-release/release-notes-generator": "^14.1.0",