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/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 clients = clientManager.getConnectedClients();
61
- const perServer = await Promise.all(clients.map(async ({ name, client }) => {
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
- return perServer.flat();
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
- return { tools: filteredTools };
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) => !compressionCache.hasCompressed(tool.serverName, tool.toolName))
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) => !compressionCache.hasCompressed(tool.serverName, tool.toolName))
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
- const client = clientManager.getClient(serverName);
684
- if (!client) {
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
- type: 'text',
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