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.
- package/CHANGELOG.md +22 -0
- package/README.md +227 -659
- package/dist/cli/commands.d.ts +18 -0
- package/dist/cli/commands.js +152 -13
- package/dist/cli/daemon.js +133 -49
- package/dist/cli/index.js +147 -7
- package/dist/cli/payload-interceptor.d.ts +71 -2
- package/dist/cli/payload-interceptor.js +214 -36
- package/dist/cli/runtime-mode.d.ts +3 -0
- package/dist/cli/runtime-mode.js +12 -0
- package/dist/cli/runtime-paths.d.ts +15 -0
- package/dist/cli/runtime-paths.js +26 -0
- package/dist/config/loader.d.ts +4 -0
- package/dist/config/loader.js +92 -1
- package/dist/config/schema.d.ts +90 -1
- package/dist/config/schema.js +95 -13
- package/dist/index.js +319 -28
- package/dist/mcp/call-script.d.ts +33 -0
- package/dist/mcp/call-script.js +153 -0
- package/dist/mcp/client-manager.d.ts +110 -27
- package/dist/mcp/client-manager.js +706 -83
- package/dist/mcp/tool-call-executor.d.ts +11 -0
- package/dist/mcp/tool-call-executor.js +94 -0
- package/dist/services/compression-cache.d.ts +18 -0
- package/dist/services/compression-cache.js +32 -0
- package/dist/services/session-manager.js +5 -0
- package/dist/services/stats-service.d.ts +4 -0
- package/dist/services/stats-service.js +18 -39
- package/dist/types/index.d.ts +59 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
5
|
import { MCPClientManager } from './mcp/client-manager.js';
|
|
6
|
+
import { callToolWithAuthRecovery } from './mcp/tool-call-executor.js';
|
|
6
7
|
import { CompressionCache } from './services/compression-cache.js';
|
|
7
8
|
import { SessionManager } from './services/session-manager.js';
|
|
8
9
|
import { loadJSONServersCached, matchesIgnorePattern } from './config/loader.js';
|
|
@@ -12,6 +13,9 @@ import pino from 'pino';
|
|
|
12
13
|
import { StatsService } from './services/stats-service.js';
|
|
13
14
|
import { CompressionSampler } from './services/compression-sampler.js';
|
|
14
15
|
import { SERVER_NAME, VERSION } from './version.js';
|
|
16
|
+
import { DEFAULT_PAYLOAD_THRESHOLD, PayloadStore } from './cli/payload-interceptor.js';
|
|
17
|
+
import { runCallScript } from './mcp/call-script.js';
|
|
18
|
+
import { getDaemonRuntimePaths } from './cli/runtime-paths.js';
|
|
15
19
|
/**
|
|
16
20
|
* MCP Server that aggregates tools from multiple MCP servers
|
|
17
21
|
* with LLM-based description compression
|
|
@@ -31,6 +35,10 @@ const logger = pino({
|
|
|
31
35
|
});
|
|
32
36
|
// Initialize services
|
|
33
37
|
const clientManager = new MCPClientManager(logger);
|
|
38
|
+
const payloadStore = new PayloadStore({
|
|
39
|
+
directory: getDaemonRuntimePaths().payloadDir,
|
|
40
|
+
removeDirectoryOnDestroy: false,
|
|
41
|
+
});
|
|
34
42
|
const compressionCache = new CompressionCache(logger);
|
|
35
43
|
const sessionManager = new SessionManager(logger);
|
|
36
44
|
const statsService = new StatsService(logger, clientManager, compressionCache, sessionManager);
|
|
@@ -50,17 +58,51 @@ const compressionSampler = new CompressionSampler(logger, {
|
|
|
50
58
|
getClientCapabilities: () => server.getClientCapabilities(),
|
|
51
59
|
createMessage: (params) => server.createMessage(params),
|
|
52
60
|
});
|
|
61
|
+
function toolResultText(result) {
|
|
62
|
+
return result.content
|
|
63
|
+
.flatMap((item) => (item.type === 'text' && item.text ? [item.text] : []))
|
|
64
|
+
.join('\n');
|
|
65
|
+
}
|
|
66
|
+
async function executeBackendTool(serverName, toolName, args) {
|
|
67
|
+
const result = await callToolWithAuthRecovery(clientManager, logger, serverName, toolName, args);
|
|
68
|
+
return { result, output: toolResultText(result) };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* How long a backend tool snapshot stays reusable.
|
|
72
|
+
*
|
|
73
|
+
* `tools/list` runs on every client refresh and the compression tools each
|
|
74
|
+
* trigger their own fan-out, so without this a single agent turn can issue
|
|
75
|
+
* several `listTools` round-trips per backend. Short enough that a genuinely
|
|
76
|
+
* changed backend surfaces almost immediately.
|
|
77
|
+
*/
|
|
78
|
+
const TOOL_CACHE_TTL_MS = 3000;
|
|
79
|
+
let toolCache;
|
|
53
80
|
/**
|
|
54
81
|
* Fetch every tool from every connected backend server, once.
|
|
55
82
|
*
|
|
56
83
|
* Callers that need both the tool list and derived counts should reuse a single
|
|
57
84
|
* snapshot rather than calling `listTools` per tool.
|
|
85
|
+
*
|
|
86
|
+
* Excluded tools are dropped here rather than only at the `tools/list` edge:
|
|
87
|
+
* every consumer of this snapshot - the compression tools included - must agree
|
|
88
|
+
* on which tools exist, or the proxy asks the model to spend calls compressing
|
|
89
|
+
* tools it will never advertise and reports coverage percentages that disagree
|
|
90
|
+
* with what the client actually sees.
|
|
58
91
|
*/
|
|
59
92
|
async function fetchAllBackendTools() {
|
|
60
|
-
const
|
|
61
|
-
const
|
|
93
|
+
const serverNames = clientManager.getConfiguredServerNames();
|
|
94
|
+
const excludePatterns = loadJSONServersCached()?.excludePatterns || [];
|
|
95
|
+
// Keyed on the connected set and the exclude patterns as well as the clock.
|
|
96
|
+
// Hot-reload can add or drop a backend between ticks and an edited
|
|
97
|
+
// servers.json can change what is filtered; serving either from a stale
|
|
98
|
+
// snapshot would contradict what tools/list reports.
|
|
99
|
+
const cacheKey = JSON.stringify([excludePatterns, [...serverNames].sort()]);
|
|
100
|
+
if (toolCache && toolCache.expiresAt > Date.now() && toolCache.key === cacheKey) {
|
|
101
|
+
return toolCache.tools;
|
|
102
|
+
}
|
|
103
|
+
const perServer = await Promise.all(serverNames.map(async (name) => {
|
|
62
104
|
try {
|
|
63
|
-
const result = await client.listTools();
|
|
105
|
+
const result = await clientManager.withClient(name, async ({ client }) => client.listTools());
|
|
64
106
|
return result.tools.map((tool) => ({
|
|
65
107
|
serverName: name,
|
|
66
108
|
toolName: tool.name,
|
|
@@ -73,12 +115,51 @@ async function fetchAllBackendTools() {
|
|
|
73
115
|
return [];
|
|
74
116
|
}
|
|
75
117
|
}));
|
|
76
|
-
|
|
118
|
+
const tools = perServer
|
|
119
|
+
.flat()
|
|
120
|
+
.filter((tool) => !matchesIgnorePattern(`${tool.serverName}__${tool.toolName}`, excludePatterns));
|
|
121
|
+
toolCache = { expiresAt: Date.now() + TOOL_CACHE_TTL_MS, key: cacheKey, tools };
|
|
122
|
+
return tools;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Whether a tool still needs compressing: never compressed, or compressed from
|
|
126
|
+
* a description the backend has since changed.
|
|
127
|
+
*/
|
|
128
|
+
function needsCompression(tool) {
|
|
129
|
+
return (!compressionCache.hasCompressed(tool.serverName, tool.toolName) ||
|
|
130
|
+
compressionCache.isStale(tool.serverName, tool.toolName, tool.description));
|
|
131
|
+
}
|
|
132
|
+
/** Tools returned per `tools/list` page when the client does not stop early. */
|
|
133
|
+
const DEFAULT_TOOLS_PAGE_SIZE = 100;
|
|
134
|
+
/**
|
|
135
|
+
* Page size, overridable so a test can force pagination without standing up a
|
|
136
|
+
* backend that exposes hundreds of tools. Anything unparseable or non-positive
|
|
137
|
+
* falls back rather than producing an empty page forever.
|
|
138
|
+
*/
|
|
139
|
+
function toolsPageSize() {
|
|
140
|
+
const configured = Number.parseInt(process.env.MCP_TOOLS_PAGE_SIZE ?? '', 10);
|
|
141
|
+
return Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_TOOLS_PAGE_SIZE;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Decode a pagination cursor into an offset.
|
|
145
|
+
*
|
|
146
|
+
* Cursors are opaque to the client but are just offsets here - this is a local
|
|
147
|
+
* 1:1 stdio transport, so there is nothing to tamper-proof against. Returns
|
|
148
|
+
* `undefined` for a cursor that cannot be honoured, which the caller reports
|
|
149
|
+
* rather than treating as "start over".
|
|
150
|
+
*/
|
|
151
|
+
function parseCursor(cursor) {
|
|
152
|
+
if (cursor === undefined)
|
|
153
|
+
return 0;
|
|
154
|
+
if (typeof cursor !== 'string')
|
|
155
|
+
return undefined;
|
|
156
|
+
const offset = Number.parseInt(cursor, 10);
|
|
157
|
+
return Number.isInteger(offset) && offset >= 0 && String(offset) === cursor ? offset : undefined;
|
|
77
158
|
}
|
|
78
159
|
/**
|
|
79
160
|
* List all tools from aggregated MCP servers + management tools
|
|
80
161
|
*/
|
|
81
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
162
|
+
server.setRequestHandler(ListToolsRequestSchema, async (request) => {
|
|
82
163
|
logger.debug('Handling tools/list request');
|
|
83
164
|
// Fetch backend tools first so the management tools can advertise live
|
|
84
165
|
// coverage numbers derived from this same snapshot.
|
|
@@ -177,6 +258,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
177
258
|
},
|
|
178
259
|
},
|
|
179
260
|
},
|
|
261
|
+
{
|
|
262
|
+
name: 'mcp-compression-proxy__invalidate_tool_cache',
|
|
263
|
+
description: "Drop one tool's cached compressed description so it is compressed again. Use when a compression lost something important; descriptions that merely went stale are re-queued automatically.",
|
|
264
|
+
inputSchema: {
|
|
265
|
+
type: 'object',
|
|
266
|
+
properties: {
|
|
267
|
+
serverName: {
|
|
268
|
+
type: 'string',
|
|
269
|
+
description: 'Server name (e.g., "filesystem")',
|
|
270
|
+
},
|
|
271
|
+
toolName: {
|
|
272
|
+
type: 'string',
|
|
273
|
+
description: 'Tool name (e.g., "read_file")',
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
required: ['serverName', 'toolName'],
|
|
277
|
+
},
|
|
278
|
+
},
|
|
180
279
|
{
|
|
181
280
|
name: 'mcp-compression-proxy__expand_tool',
|
|
182
281
|
description: 'Expand a tool to show its full original description (session-specific)',
|
|
@@ -248,6 +347,61 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
248
347
|
},
|
|
249
348
|
},
|
|
250
349
|
},
|
|
350
|
+
{
|
|
351
|
+
name: 'mcp-compression-proxy__read_output',
|
|
352
|
+
description: 'Read a cached large tool output by payload ID. Reads 10K characters by default; use offset/length to page or all=true to return the remainder.',
|
|
353
|
+
inputSchema: {
|
|
354
|
+
type: 'object',
|
|
355
|
+
properties: {
|
|
356
|
+
id: { type: 'string', description: 'Payload ID returned by a tool call' },
|
|
357
|
+
offset: { type: 'number', minimum: 0, default: 0 },
|
|
358
|
+
length: { type: 'number', minimum: 1, default: 10000 },
|
|
359
|
+
all: { type: 'boolean', default: false },
|
|
360
|
+
},
|
|
361
|
+
required: ['id'],
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: 'mcp-compression-proxy__find_output',
|
|
366
|
+
description: 'Find literal text inside a cached large tool output without loading the full payload into context.',
|
|
367
|
+
inputSchema: {
|
|
368
|
+
type: 'object',
|
|
369
|
+
properties: {
|
|
370
|
+
id: { type: 'string', description: 'Payload ID returned by a tool call' },
|
|
371
|
+
query: { type: 'string', minLength: 1 },
|
|
372
|
+
caseSensitive: { type: 'boolean', default: false },
|
|
373
|
+
maxMatches: { type: 'number', minimum: 1, maximum: 100, default: 20 },
|
|
374
|
+
contextChars: { type: 'number', minimum: 0, maximum: 2000, default: 200 },
|
|
375
|
+
},
|
|
376
|
+
required: ['id', 'query'],
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
name: 'mcp-compression-proxy__run_script',
|
|
381
|
+
description: 'Run up to 20 MCP calls sequentially. Later arguments may reference prior JSON output with {"$ref":"stepId#/json/pointer"}. This is declarative and does not execute shell or JavaScript.',
|
|
382
|
+
inputSchema: {
|
|
383
|
+
type: 'object',
|
|
384
|
+
properties: {
|
|
385
|
+
steps: {
|
|
386
|
+
type: 'array',
|
|
387
|
+
minItems: 1,
|
|
388
|
+
maxItems: 20,
|
|
389
|
+
items: {
|
|
390
|
+
type: 'object',
|
|
391
|
+
properties: {
|
|
392
|
+
id: { type: 'string', minLength: 1 },
|
|
393
|
+
server: { type: 'string', minLength: 1 },
|
|
394
|
+
tool: { type: 'string', minLength: 1 },
|
|
395
|
+
arguments: { type: 'object' },
|
|
396
|
+
continueOnError: { type: 'boolean', default: false },
|
|
397
|
+
},
|
|
398
|
+
required: ['id', 'server', 'tool'],
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
required: ['steps'],
|
|
403
|
+
},
|
|
404
|
+
},
|
|
251
405
|
];
|
|
252
406
|
const aggregatedTools = backendTools.map((tool) => {
|
|
253
407
|
// Check if tool is expanded in current session
|
|
@@ -264,7 +418,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
264
418
|
// Apply exclude patterns to filter out tools
|
|
265
419
|
const config = loadJSONServersCached();
|
|
266
420
|
const excludePatterns = config?.excludePatterns || [];
|
|
267
|
-
const filteredTools = allTools.filter(tool => {
|
|
421
|
+
const filteredTools = allTools.filter((tool) => {
|
|
268
422
|
const isExcluded = matchesIgnorePattern(tool.name, excludePatterns);
|
|
269
423
|
if (isExcluded) {
|
|
270
424
|
logger.debug({ tool: tool.name }, 'Tool excluded by pattern');
|
|
@@ -272,7 +426,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
272
426
|
return !isExcluded;
|
|
273
427
|
});
|
|
274
428
|
logger.debug({ count: filteredTools.length, excluded: allTools.length - filteredTools.length }, 'Returning tools');
|
|
275
|
-
|
|
429
|
+
// Paginate over the post-exclude list: a cursor pointing into the unfiltered
|
|
430
|
+
// set would drift as patterns change, and would leak excluded tools at the
|
|
431
|
+
// page boundaries.
|
|
432
|
+
const offset = parseCursor(request.params?.cursor);
|
|
433
|
+
if (offset === undefined) {
|
|
434
|
+
return {
|
|
435
|
+
tools: [],
|
|
436
|
+
// The spec has no error channel here, so an unusable cursor returns
|
|
437
|
+
// nothing rather than silently restarting from the top - a caller
|
|
438
|
+
// looping on nextCursor would otherwise never terminate.
|
|
439
|
+
_meta: { error: `Invalid cursor: ${String(request.params?.cursor)}` },
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
const pageSize = toolsPageSize();
|
|
443
|
+
const page = filteredTools.slice(offset, offset + pageSize);
|
|
444
|
+
const nextOffset = offset + page.length;
|
|
445
|
+
return {
|
|
446
|
+
tools: page,
|
|
447
|
+
...(nextOffset < filteredTools.length ? { nextCursor: String(nextOffset) } : {}),
|
|
448
|
+
};
|
|
276
449
|
});
|
|
277
450
|
/**
|
|
278
451
|
* Call a tool (either management tool or aggregated MCP tool)
|
|
@@ -365,8 +538,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
365
538
|
const backendTools = await fetchAllBackendTools();
|
|
366
539
|
const coverage = statsService.computeCoverage(backendTools);
|
|
367
540
|
const liveStats = statsService.formatCoverage(coverage);
|
|
541
|
+
// Stale entries rejoin the queue alongside never-compressed ones, so a
|
|
542
|
+
// backend that rewrites a description is picked up by the existing
|
|
543
|
+
// compress -> cache loop without the caller learning a new concept.
|
|
368
544
|
const allUncompressedTools = backendTools
|
|
369
|
-
.filter((tool) =>
|
|
545
|
+
.filter((tool) => needsCompression(tool))
|
|
370
546
|
.map((tool) => ({
|
|
371
547
|
serverName: tool.serverName,
|
|
372
548
|
toolName: tool.toolName,
|
|
@@ -521,6 +697,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
521
697
|
],
|
|
522
698
|
};
|
|
523
699
|
}
|
|
700
|
+
if (name === 'mcp-compression-proxy__invalidate_tool_cache') {
|
|
701
|
+
const { serverName, toolName } = args;
|
|
702
|
+
const removed = compressionCache.invalidate(serverName, toolName);
|
|
703
|
+
if (!removed) {
|
|
704
|
+
return {
|
|
705
|
+
content: [
|
|
706
|
+
{
|
|
707
|
+
type: 'text',
|
|
708
|
+
text: `No cached compression found for ${serverName}:${toolName}. Nothing to invalidate.`,
|
|
709
|
+
},
|
|
710
|
+
],
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
await compressionCache.saveToDisk();
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
logger.error({ error, serverName, toolName }, 'Failed to persist cache after invalidation');
|
|
718
|
+
return {
|
|
719
|
+
content: [
|
|
720
|
+
{
|
|
721
|
+
type: 'text',
|
|
722
|
+
text: `Invalidated ${serverName}:${toolName} in memory, but persisting the cache failed: ${error instanceof Error ? error.message : 'Unknown error'}. The entry will come back on restart.`,
|
|
723
|
+
},
|
|
724
|
+
],
|
|
725
|
+
isError: true,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
return {
|
|
729
|
+
content: [
|
|
730
|
+
{
|
|
731
|
+
type: 'text',
|
|
732
|
+
text: `Invalidated the cached compression for ${serverName}:${toolName}.\n\nIt will be offered again by mcp-compression-proxy__get_uncompressed_tools.`,
|
|
733
|
+
},
|
|
734
|
+
],
|
|
735
|
+
};
|
|
736
|
+
}
|
|
524
737
|
if (name === 'mcp-compression-proxy__expand_tool') {
|
|
525
738
|
const { serverName, toolName } = args;
|
|
526
739
|
if (!currentSessionId) {
|
|
@@ -597,7 +810,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
597
810
|
const backendTools = await fetchAllBackendTools();
|
|
598
811
|
const coverageBefore = statsService.computeCoverage(backendTools);
|
|
599
812
|
const uncompressed = backendTools
|
|
600
|
-
.filter((tool) =>
|
|
813
|
+
.filter((tool) => needsCompression(tool))
|
|
601
814
|
.slice(0, actualLimit);
|
|
602
815
|
if (uncompressed.length === 0) {
|
|
603
816
|
return {
|
|
@@ -663,6 +876,77 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
663
876
|
};
|
|
664
877
|
}
|
|
665
878
|
}
|
|
879
|
+
if (name === 'mcp-compression-proxy__read_output') {
|
|
880
|
+
const { id, offset, length, all } = args;
|
|
881
|
+
try {
|
|
882
|
+
const result = payloadStore.read(id, { offset, length, all });
|
|
883
|
+
return {
|
|
884
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
return {
|
|
889
|
+
content: [
|
|
890
|
+
{
|
|
891
|
+
type: 'text',
|
|
892
|
+
text: error instanceof Error ? error.message : String(error),
|
|
893
|
+
},
|
|
894
|
+
],
|
|
895
|
+
isError: true,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (name === 'mcp-compression-proxy__find_output') {
|
|
900
|
+
const { id, query, caseSensitive, maxMatches, contextChars } = args;
|
|
901
|
+
try {
|
|
902
|
+
const result = payloadStore.find(id, query, {
|
|
903
|
+
caseSensitive,
|
|
904
|
+
maxMatches,
|
|
905
|
+
contextChars,
|
|
906
|
+
});
|
|
907
|
+
return {
|
|
908
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
catch (error) {
|
|
912
|
+
return {
|
|
913
|
+
content: [
|
|
914
|
+
{
|
|
915
|
+
type: 'text',
|
|
916
|
+
text: error instanceof Error ? error.message : String(error),
|
|
917
|
+
},
|
|
918
|
+
],
|
|
919
|
+
isError: true,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (name === 'mcp-compression-proxy__run_script') {
|
|
924
|
+
const { steps } = args;
|
|
925
|
+
try {
|
|
926
|
+
const config = loadJSONServersCached();
|
|
927
|
+
const result = await runCallScript(steps, async (serverName, toolName, stepArgs) => {
|
|
928
|
+
const executed = await executeBackendTool(serverName, toolName, stepArgs);
|
|
929
|
+
return {
|
|
930
|
+
output: executed.output,
|
|
931
|
+
isError: executed.result.isError,
|
|
932
|
+
};
|
|
933
|
+
}, payloadStore, config?.cli?.payloadThreshold ?? DEFAULT_PAYLOAD_THRESHOLD);
|
|
934
|
+
return {
|
|
935
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
catch (error) {
|
|
939
|
+
return {
|
|
940
|
+
content: [
|
|
941
|
+
{
|
|
942
|
+
type: 'text',
|
|
943
|
+
text: error instanceof Error ? error.message : String(error),
|
|
944
|
+
},
|
|
945
|
+
],
|
|
946
|
+
isError: true,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
}
|
|
666
950
|
// Aggregated MCP tool call
|
|
667
951
|
// Tool name format: "serverName__toolName". Split on the first separator
|
|
668
952
|
// only - backend tools are free to have "__" in their own names.
|
|
@@ -680,25 +964,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
680
964
|
}
|
|
681
965
|
const serverName = name.slice(0, separatorIndex);
|
|
682
966
|
const toolName = name.slice(separatorIndex + 2);
|
|
683
|
-
|
|
684
|
-
|
|
967
|
+
try {
|
|
968
|
+
const executed = await executeBackendTool(serverName, toolName, (args || {}));
|
|
969
|
+
const threshold = loadJSONServersCached()?.cli?.payloadThreshold ?? DEFAULT_PAYLOAD_THRESHOLD;
|
|
970
|
+
const captured = payloadStore.capture(executed.output, threshold);
|
|
971
|
+
if (!captured.reference) {
|
|
972
|
+
return executed.result;
|
|
973
|
+
}
|
|
685
974
|
return {
|
|
686
|
-
content: [
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
text: `Error: Server '${serverName}' not found or not connected`,
|
|
690
|
-
},
|
|
691
|
-
],
|
|
692
|
-
isError: true,
|
|
975
|
+
content: [{ type: 'text', text: captured.output }],
|
|
976
|
+
isError: executed.result.isError,
|
|
977
|
+
structuredContent: { payload: captured.reference },
|
|
693
978
|
};
|
|
694
979
|
}
|
|
695
|
-
try {
|
|
696
|
-
const result = await client.callTool({
|
|
697
|
-
name: toolName,
|
|
698
|
-
arguments: args || {},
|
|
699
|
-
});
|
|
700
|
-
return result;
|
|
701
|
-
}
|
|
702
980
|
catch (error) {
|
|
703
981
|
logger.error({ serverName, toolName, error }, 'Tool call failed');
|
|
704
982
|
return {
|
|
@@ -737,6 +1015,7 @@ async function shutdown(reason, exitCode = 0) {
|
|
|
737
1015
|
logger.debug({ error }, 'Error while closing server transport');
|
|
738
1016
|
}
|
|
739
1017
|
sessionManager.destroy();
|
|
1018
|
+
payloadStore.destroy();
|
|
740
1019
|
process.exit(exitCode);
|
|
741
1020
|
}
|
|
742
1021
|
/**
|
|
@@ -782,24 +1061,36 @@ async function main() {
|
|
|
782
1061
|
compressionCache.setNoCompressPatterns(config.noCompressPatterns);
|
|
783
1062
|
compressionCache.setFallbackBehavior(config.compressionFallbackBehavior ?? 'original');
|
|
784
1063
|
// Initialize MCP clients (only enabled servers)
|
|
785
|
-
const enabledServers = config.servers.filter(server => {
|
|
1064
|
+
const enabledServers = config.servers.filter((server) => {
|
|
786
1065
|
// Server is enabled if enabled field is not explicitly false
|
|
787
1066
|
return server.enabled !== false;
|
|
788
1067
|
});
|
|
789
1068
|
logger.info({
|
|
790
1069
|
total: config.servers.length,
|
|
791
1070
|
enabled: enabledServers.length,
|
|
792
|
-
servers: enabledServers.map(s => s.name)
|
|
1071
|
+
servers: enabledServers.map((s) => s.name),
|
|
793
1072
|
}, 'Initializing backend MCP servers with timeout protection');
|
|
794
1073
|
// Wait for all servers to initialize or timeout before reporting ready
|
|
795
1074
|
try {
|
|
796
|
-
await clientManager.initializeServers(enabledServers, config.defaultTimeout, config.inheritEnv
|
|
1075
|
+
await clientManager.initializeServers(enabledServers, config.defaultTimeout, config.inheritEnv, {
|
|
1076
|
+
softMaxConnectionAgeSeconds: config.softMaxConnectionAgeSeconds,
|
|
1077
|
+
hardMaxConnectionAgeSeconds: config.hardMaxConnectionAgeSeconds,
|
|
1078
|
+
authErrorPatterns: config.authErrorPatterns,
|
|
1079
|
+
authRetryTools: config.authRetryTools,
|
|
1080
|
+
});
|
|
797
1081
|
logger.info('Backend MCP servers initialization complete');
|
|
798
1082
|
}
|
|
799
1083
|
catch (error) {
|
|
800
1084
|
logger.error({ error }, 'Error during backend server initialization');
|
|
801
1085
|
}
|
|
802
1086
|
}
|
|
1087
|
+
// Outside the branch above on purpose: the fingerprint the watch polls counts
|
|
1088
|
+
// a missing config file, so a user who writes their first servers.json after
|
|
1089
|
+
// starting the proxy gets their servers without restarting the MCP client.
|
|
1090
|
+
clientManager.startConfigWatch(loadJSONServersCached, undefined, (reloaded) => {
|
|
1091
|
+
compressionCache.setNoCompressPatterns(reloaded.noCompressPatterns);
|
|
1092
|
+
compressionCache.setFallbackBehavior(reloaded.compressionFallbackBehavior ?? 'original');
|
|
1093
|
+
});
|
|
803
1094
|
// Now connect to the MCP client - all backend servers are ready (or timed out)
|
|
804
1095
|
const transport = new StdioServerTransport();
|
|
805
1096
|
// When the client disconnects, take the backend servers down with us.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type PayloadReference, type PayloadStore } from '../cli/payload-interceptor.js';
|
|
2
|
+
export declare const MAX_CALL_SCRIPT_STEPS = 20;
|
|
3
|
+
export interface CallScriptStep {
|
|
4
|
+
id: string;
|
|
5
|
+
server: string;
|
|
6
|
+
tool: string;
|
|
7
|
+
arguments?: Record<string, unknown>;
|
|
8
|
+
continueOnError?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface CallScriptStepResult {
|
|
11
|
+
id: string;
|
|
12
|
+
server: string;
|
|
13
|
+
tool: string;
|
|
14
|
+
output: string;
|
|
15
|
+
isError?: boolean;
|
|
16
|
+
payload?: PayloadReference;
|
|
17
|
+
}
|
|
18
|
+
export interface CallScriptResult {
|
|
19
|
+
steps: CallScriptStepResult[];
|
|
20
|
+
stoppedAt?: string;
|
|
21
|
+
}
|
|
22
|
+
export type ScriptCallExecutor = (server: string, tool: string, args: Record<string, unknown>) => Promise<{
|
|
23
|
+
output: string;
|
|
24
|
+
isError?: boolean;
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* Execute a bounded, declarative call chain.
|
|
28
|
+
*
|
|
29
|
+
* References use {"$ref":"stepId#/json/pointer"} and can only target earlier
|
|
30
|
+
* steps. No arbitrary JavaScript or shell is evaluated.
|
|
31
|
+
*/
|
|
32
|
+
export declare function runCallScript(steps: CallScriptStep[], execute: ScriptCallExecutor, payloadStore: PayloadStore, payloadThreshold?: number): Promise<CallScriptResult>;
|
|
33
|
+
//# sourceMappingURL=call-script.d.ts.map
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { DEFAULT_PAYLOAD_THRESHOLD, } from '../cli/payload-interceptor.js';
|
|
2
|
+
export const MAX_CALL_SCRIPT_STEPS = 20;
|
|
3
|
+
function decodePointerSegment(segment) {
|
|
4
|
+
return segment.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
5
|
+
}
|
|
6
|
+
function resolveJsonPointer(value, pointer) {
|
|
7
|
+
if (pointer === '')
|
|
8
|
+
return value;
|
|
9
|
+
if (!pointer.startsWith('/')) {
|
|
10
|
+
throw new Error(`JSON Pointer must be empty or start with "/": "${pointer}"`);
|
|
11
|
+
}
|
|
12
|
+
let current = value;
|
|
13
|
+
for (const rawSegment of pointer.slice(1).split('/')) {
|
|
14
|
+
const segment = decodePointerSegment(rawSegment);
|
|
15
|
+
if (Array.isArray(current)) {
|
|
16
|
+
if (!/^(0|[1-9]\d*)$/.test(segment)) {
|
|
17
|
+
throw new Error(`JSON Pointer array index is invalid: "${segment}"`);
|
|
18
|
+
}
|
|
19
|
+
const index = Number(segment);
|
|
20
|
+
if (index >= current.length) {
|
|
21
|
+
throw new Error(`JSON Pointer array index is out of range: ${index}`);
|
|
22
|
+
}
|
|
23
|
+
current = current[index];
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (typeof current === 'object' && current !== null) {
|
|
27
|
+
if (!Object.prototype.hasOwnProperty.call(current, segment)) {
|
|
28
|
+
throw new Error(`JSON Pointer property not found: "${segment}"`);
|
|
29
|
+
}
|
|
30
|
+
current = current[segment];
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`JSON Pointer cannot traverse through "${segment}"`);
|
|
34
|
+
}
|
|
35
|
+
return current;
|
|
36
|
+
}
|
|
37
|
+
function resolveReference(reference, priorResults) {
|
|
38
|
+
const hashIndex = reference.indexOf('#');
|
|
39
|
+
const stepId = hashIndex === -1 ? reference : reference.slice(0, hashIndex);
|
|
40
|
+
const pointer = hashIndex === -1 ? '' : reference.slice(hashIndex + 1);
|
|
41
|
+
if (!priorResults.has(stepId)) {
|
|
42
|
+
throw new Error(`Unknown prior step "${stepId}" in reference "${reference}"`);
|
|
43
|
+
}
|
|
44
|
+
return resolveJsonPointer(priorResults.get(stepId), pointer);
|
|
45
|
+
}
|
|
46
|
+
function resolveReferences(value, priorResults) {
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
return value.map((item) => resolveReferences(item, priorResults));
|
|
49
|
+
}
|
|
50
|
+
if (typeof value === 'object' && value !== null) {
|
|
51
|
+
const entries = Object.entries(value);
|
|
52
|
+
if (entries.length === 1 &&
|
|
53
|
+
entries[0][0] === '$ref' &&
|
|
54
|
+
typeof entries[0][1] === 'string') {
|
|
55
|
+
return resolveReference(entries[0][1], priorResults);
|
|
56
|
+
}
|
|
57
|
+
return Object.fromEntries(entries.map(([key, nested]) => [
|
|
58
|
+
key,
|
|
59
|
+
resolveReferences(nested, priorResults),
|
|
60
|
+
]));
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
function parseOutput(output) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(output);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return output;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Execute a bounded, declarative call chain.
|
|
74
|
+
*
|
|
75
|
+
* References use {"$ref":"stepId#/json/pointer"} and can only target earlier
|
|
76
|
+
* steps. No arbitrary JavaScript or shell is evaluated.
|
|
77
|
+
*/
|
|
78
|
+
export async function runCallScript(steps, execute, payloadStore, payloadThreshold = DEFAULT_PAYLOAD_THRESHOLD) {
|
|
79
|
+
if (steps.length > MAX_CALL_SCRIPT_STEPS) {
|
|
80
|
+
throw new Error(`Call scripts may contain at most ${MAX_CALL_SCRIPT_STEPS} steps`);
|
|
81
|
+
}
|
|
82
|
+
const ids = new Set();
|
|
83
|
+
for (const step of steps) {
|
|
84
|
+
if (!step.id) {
|
|
85
|
+
throw new Error('Every call script step requires a non-empty id');
|
|
86
|
+
}
|
|
87
|
+
if (ids.has(step.id)) {
|
|
88
|
+
throw new Error(`Duplicate script step id: "${step.id}"`);
|
|
89
|
+
}
|
|
90
|
+
ids.add(step.id);
|
|
91
|
+
}
|
|
92
|
+
const priorResults = new Map();
|
|
93
|
+
const results = [];
|
|
94
|
+
for (const step of steps) {
|
|
95
|
+
let resolvedArguments;
|
|
96
|
+
try {
|
|
97
|
+
const resolved = resolveReferences(step.arguments ?? {}, priorResults);
|
|
98
|
+
if (typeof resolved !== 'object' ||
|
|
99
|
+
resolved === null ||
|
|
100
|
+
Array.isArray(resolved)) {
|
|
101
|
+
throw new Error(`Arguments for step "${step.id}" must resolve to an object`);
|
|
102
|
+
}
|
|
103
|
+
resolvedArguments = resolved;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
const output = error instanceof Error ? error.message : String(error);
|
|
107
|
+
results.push({
|
|
108
|
+
id: step.id,
|
|
109
|
+
server: step.server,
|
|
110
|
+
tool: step.tool,
|
|
111
|
+
output,
|
|
112
|
+
isError: true,
|
|
113
|
+
});
|
|
114
|
+
if (!step.continueOnError) {
|
|
115
|
+
return { steps: results, stoppedAt: step.id };
|
|
116
|
+
}
|
|
117
|
+
priorResults.set(step.id, output);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const callResult = await execute(step.server, step.tool, resolvedArguments);
|
|
122
|
+
priorResults.set(step.id, parseOutput(callResult.output));
|
|
123
|
+
const captured = payloadStore.capture(callResult.output, payloadThreshold);
|
|
124
|
+
results.push({
|
|
125
|
+
id: step.id,
|
|
126
|
+
server: step.server,
|
|
127
|
+
tool: step.tool,
|
|
128
|
+
output: captured.output,
|
|
129
|
+
isError: callResult.isError,
|
|
130
|
+
payload: captured.reference,
|
|
131
|
+
});
|
|
132
|
+
if (callResult.isError && !step.continueOnError) {
|
|
133
|
+
return { steps: results, stoppedAt: step.id };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
const output = error instanceof Error ? error.message : String(error);
|
|
138
|
+
results.push({
|
|
139
|
+
id: step.id,
|
|
140
|
+
server: step.server,
|
|
141
|
+
tool: step.tool,
|
|
142
|
+
output,
|
|
143
|
+
isError: true,
|
|
144
|
+
});
|
|
145
|
+
if (!step.continueOnError) {
|
|
146
|
+
return { steps: results, stoppedAt: step.id };
|
|
147
|
+
}
|
|
148
|
+
priorResults.set(step.id, output);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { steps: results };
|
|
152
|
+
}
|
|
153
|
+
//# sourceMappingURL=call-script.js.map
|