@anyshift/mcp-proxy 0.2.0 → 0.2.1

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,127 @@
1
+ /**
2
+ * Test utilities and helpers for MCP Proxy tests
3
+ */
4
+ /**
5
+ * Create an MCP response with specified character count
6
+ * @param charCount - Number of characters in the response
7
+ * @param jsonData - Optional JSON data to include (overrides charCount)
8
+ * @returns MCP-formatted response
9
+ */
10
+ export declare function createMCPResponse(charCount: number, jsonData?: object): {
11
+ content: {
12
+ type: string;
13
+ text: string;
14
+ }[];
15
+ };
16
+ /**
17
+ * Create an MCP response with specific JSON data
18
+ */
19
+ export declare function createMCPResponseWithJSON(jsonData: object): {
20
+ content: {
21
+ type: string;
22
+ text: string;
23
+ }[];
24
+ };
25
+ /**
26
+ * Create an error response
27
+ */
28
+ export declare function createErrorResponse(errorMessage: string): {
29
+ content: {
30
+ type: string;
31
+ text: string;
32
+ }[];
33
+ isError: boolean;
34
+ };
35
+ /**
36
+ * Extract file path from file reference response
37
+ */
38
+ export declare function extractFilePath(result: any): string;
39
+ /**
40
+ * Extract reported file size from file reference
41
+ */
42
+ export declare function extractReportedSize(result: any): number;
43
+ /**
44
+ * Check if response is a file reference
45
+ */
46
+ export declare function isFileReference(result: any): boolean;
47
+ /**
48
+ * Check if response is truncated
49
+ */
50
+ export declare function isTruncated(result: any): boolean;
51
+ /**
52
+ * Create a mock file system for testing
53
+ */
54
+ export declare class MockFileSystem {
55
+ private files;
56
+ writeFile(path: string, content: string): Promise<void>;
57
+ readFile(path: string): Promise<string>;
58
+ existsSync(path: string): boolean;
59
+ clear(): void;
60
+ getFiles(): string[];
61
+ }
62
+ /**
63
+ * Test fixtures for common scenarios
64
+ */
65
+ export declare const fixtures: {
66
+ smallResponse: {
67
+ content: {
68
+ type: string;
69
+ text: string;
70
+ }[];
71
+ };
72
+ mediumResponse: {
73
+ content: {
74
+ type: string;
75
+ text: string;
76
+ }[];
77
+ };
78
+ largeResponse: {
79
+ content: {
80
+ type: string;
81
+ text: string;
82
+ }[];
83
+ };
84
+ atThreshold: {
85
+ content: {
86
+ type: string;
87
+ text: string;
88
+ }[];
89
+ };
90
+ justBelowThreshold: {
91
+ content: {
92
+ type: string;
93
+ text: string;
94
+ }[];
95
+ };
96
+ atTruncationLimit: {
97
+ content: {
98
+ type: string;
99
+ text: string;
100
+ }[];
101
+ };
102
+ overTruncationLimit: {
103
+ content: {
104
+ type: string;
105
+ text: string;
106
+ }[];
107
+ };
108
+ errorResponse: {
109
+ content: {
110
+ type: string;
111
+ text: string;
112
+ }[];
113
+ isError: boolean;
114
+ };
115
+ jsonResponse: {
116
+ content: {
117
+ type: string;
118
+ text: string;
119
+ }[];
120
+ };
121
+ withPagination: {
122
+ content: {
123
+ type: string;
124
+ text: string;
125
+ }[];
126
+ };
127
+ };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Test utilities and helpers for MCP Proxy tests
3
+ */
4
+ /**
5
+ * Create an MCP response with specified character count
6
+ * @param charCount - Number of characters in the response
7
+ * @param jsonData - Optional JSON data to include (overrides charCount)
8
+ * @returns MCP-formatted response
9
+ */
10
+ export function createMCPResponse(charCount, jsonData) {
11
+ const content = jsonData
12
+ ? JSON.stringify(jsonData)
13
+ : 'x'.repeat(Math.max(0, charCount - 50)) + JSON.stringify({ padding: true });
14
+ return {
15
+ content: [{ type: 'text', text: content }]
16
+ };
17
+ }
18
+ /**
19
+ * Create an MCP response with specific JSON data
20
+ */
21
+ export function createMCPResponseWithJSON(jsonData) {
22
+ return {
23
+ content: [{ type: 'text', text: JSON.stringify(jsonData) }]
24
+ };
25
+ }
26
+ /**
27
+ * Create an error response
28
+ */
29
+ export function createErrorResponse(errorMessage) {
30
+ return {
31
+ content: [{
32
+ type: 'text',
33
+ text: JSON.stringify({ status: 'error', message: errorMessage })
34
+ }],
35
+ isError: true
36
+ };
37
+ }
38
+ /**
39
+ * Extract file path from file reference response
40
+ */
41
+ export function extractFilePath(result) {
42
+ const text = result.content[0].text;
43
+ const match = text.match(/📄 File: (.+)/);
44
+ if (!match) {
45
+ throw new Error('No file path found in response');
46
+ }
47
+ return match[1].split('\n')[0].trim();
48
+ }
49
+ /**
50
+ * Extract reported file size from file reference
51
+ */
52
+ export function extractReportedSize(result) {
53
+ const text = result.content[0].text;
54
+ const match = text.match(/Size: (\d+) characters/);
55
+ if (!match) {
56
+ throw new Error('No size information found in response');
57
+ }
58
+ return parseInt(match[1], 10);
59
+ }
60
+ /**
61
+ * Check if response is a file reference
62
+ */
63
+ export function isFileReference(result) {
64
+ return result.content &&
65
+ result.content[0] &&
66
+ result.content[0].text &&
67
+ result.content[0].text.includes('📄 File:');
68
+ }
69
+ /**
70
+ * Check if response is truncated
71
+ */
72
+ export function isTruncated(result) {
73
+ return result.content &&
74
+ result.content[0] &&
75
+ result.content[0].text &&
76
+ result.content[0].text.includes('=== RESPONSE TRUNCATED ===');
77
+ }
78
+ /**
79
+ * Create a mock file system for testing
80
+ */
81
+ export class MockFileSystem {
82
+ files = new Map();
83
+ async writeFile(path, content) {
84
+ this.files.set(path, content);
85
+ }
86
+ async readFile(path) {
87
+ const content = this.files.get(path);
88
+ if (content === undefined) {
89
+ throw new Error(`File not found: ${path}`);
90
+ }
91
+ return content;
92
+ }
93
+ existsSync(path) {
94
+ return this.files.has(path);
95
+ }
96
+ clear() {
97
+ this.files.clear();
98
+ }
99
+ getFiles() {
100
+ return Array.from(this.files.keys());
101
+ }
102
+ }
103
+ /**
104
+ * Test fixtures for common scenarios
105
+ */
106
+ export const fixtures = {
107
+ smallResponse: createMCPResponse(500),
108
+ mediumResponse: createMCPResponse(5000),
109
+ largeResponse: createMCPResponse(100000),
110
+ atThreshold: createMCPResponse(1000),
111
+ justBelowThreshold: createMCPResponse(999),
112
+ atTruncationLimit: createMCPResponse(40000),
113
+ overTruncationLimit: createMCPResponse(50000),
114
+ errorResponse: createErrorResponse('Test error'),
115
+ jsonResponse: createMCPResponseWithJSON({ data: { nested: 'value' } }),
116
+ withPagination: createMCPResponseWithJSON({
117
+ data: [{ item: 1 }, { item: 2 }],
118
+ pagination: { page: 1, total: 10 },
119
+ has_more: true,
120
+ next_page: '/api/page/2'
121
+ })
122
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Unit tests for truncation module
3
+ * Tests token estimation and truncation logic
4
+ */
5
+ export {};
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Unit tests for truncation module
3
+ * Tests token estimation and truncation logic
4
+ */
5
+ import { describe, test, expect } from '@jest/globals';
6
+ import { estimateTokens, wouldBeTruncated, truncateResponseIfNeeded } from '../../truncation/truncate.js';
7
+ describe('Truncation - Token Estimation', () => {
8
+ test('estimateTokens with default ratio (4 chars/token)', () => {
9
+ expect(estimateTokens('x'.repeat(100))).toBe(25); // 100/4 = 25
10
+ expect(estimateTokens('x'.repeat(1000))).toBe(250); // 1000/4 = 250
11
+ expect(estimateTokens('x'.repeat(40000))).toBe(10000); // 40000/4 = 10000
12
+ });
13
+ test('estimateTokens with custom ratio', () => {
14
+ expect(estimateTokens('x'.repeat(100), 3)).toBe(34); // 100/3 = 33.33 -> 34 (ceil)
15
+ expect(estimateTokens('x'.repeat(100), 5)).toBe(20); // 100/5 = 20
16
+ });
17
+ test('estimateTokens handles empty string', () => {
18
+ expect(estimateTokens('')).toBe(0);
19
+ });
20
+ test('estimateTokens rounds up (ceiling)', () => {
21
+ expect(estimateTokens('xxx', 4)).toBe(1); // 3/4 = 0.75 -> 1
22
+ expect(estimateTokens('xxxxx', 4)).toBe(2); // 5/4 = 1.25 -> 2
23
+ });
24
+ });
25
+ describe('Truncation - Would Be Truncated Check', () => {
26
+ test('returns false when content is under limit', () => {
27
+ const content = 'x'.repeat(30000); // 7500 tokens
28
+ expect(wouldBeTruncated(content, 10000, 4)).toBe(false);
29
+ });
30
+ test('returns true when content exceeds limit', () => {
31
+ const content = 'x'.repeat(50000); // 12500 tokens
32
+ expect(wouldBeTruncated(content, 10000, 4)).toBe(true);
33
+ });
34
+ test('returns false when exactly at limit', () => {
35
+ const content = 'x'.repeat(40000); // exactly 10000 tokens
36
+ expect(wouldBeTruncated(content, 10000, 4)).toBe(false);
37
+ });
38
+ test('returns true when 1 char over limit', () => {
39
+ const content = 'x'.repeat(40001); // 10000.25 -> 10001 tokens
40
+ expect(wouldBeTruncated(content, 10000, 4)).toBe(true);
41
+ });
42
+ });
43
+ describe('Truncation - Truncate Response If Needed', () => {
44
+ let config;
45
+ beforeEach(() => {
46
+ config = {
47
+ maxTokens: 10000,
48
+ charsPerToken: 4,
49
+ enableLogging: false,
50
+ messagePrefix: 'RESPONSE TRUNCATED'
51
+ };
52
+ });
53
+ test('Does not truncate when under limit', () => {
54
+ const content = 'x'.repeat(30000); // 7500 tokens
55
+ const result = truncateResponseIfNeeded(config, content);
56
+ expect(result).toBe(content);
57
+ expect(result).not.toContain('=== RESPONSE TRUNCATED ===');
58
+ expect(result.length).toBe(30000);
59
+ });
60
+ test('Truncates when over limit', () => {
61
+ const content = 'x'.repeat(100000); // 25000 tokens
62
+ const result = truncateResponseIfNeeded(config, content);
63
+ expect(result).toContain('=== RESPONSE TRUNCATED ===');
64
+ expect(result).toContain('Estimated tokens: 25000 (limit: 10000)');
65
+ expect(result).toContain('Please refine your query');
66
+ expect(result).toContain('=== END TRUNCATION NOTICE ===');
67
+ });
68
+ test('Truncated content is under maxChars', () => {
69
+ const content = 'x'.repeat(100000); // 25000 tokens
70
+ const result = truncateResponseIfNeeded(config, content);
71
+ // Result should be truncation notice + 40000 chars of content
72
+ const maxChars = config.maxTokens * (config.charsPerToken || 4);
73
+ expect(result.length).toBeLessThan(maxChars + 500); // +500 for notice
74
+ });
75
+ test('Exactly at limit: no truncation', () => {
76
+ const content = 'x'.repeat(40000); // exactly 10000 tokens
77
+ const result = truncateResponseIfNeeded(config, content);
78
+ expect(result).toBe(content);
79
+ expect(result).not.toContain('=== RESPONSE TRUNCATED ===');
80
+ });
81
+ test('One char over limit: truncates', () => {
82
+ const content = 'x'.repeat(40001); // 10000.25 tokens
83
+ const result = truncateResponseIfNeeded(config, content);
84
+ expect(result).toContain('=== RESPONSE TRUNCATED ===');
85
+ });
86
+ test('Custom message prefix', () => {
87
+ config.messagePrefix = 'CUSTOM TRUNCATION MESSAGE';
88
+ const content = 'x'.repeat(100000);
89
+ const result = truncateResponseIfNeeded(config, content);
90
+ expect(result).toContain('=== CUSTOM TRUNCATION MESSAGE ===');
91
+ });
92
+ test('Custom charsPerToken ratio', () => {
93
+ config.charsPerToken = 3; // Tighter ratio
94
+ const content = 'x'.repeat(35000); // 11666 tokens with ratio of 3
95
+ const result = truncateResponseIfNeeded(config, content);
96
+ // Should truncate (11666 > 10000)
97
+ expect(result).toContain('=== RESPONSE TRUNCATED ===');
98
+ expect(result).toContain('limit: 10000');
99
+ // Truncated to 30000 chars (10000 * 3)
100
+ const truncatedContent = result.split('=== END TRUNCATION NOTICE ===')[1];
101
+ expect(truncatedContent.trim().length).toBeLessThanOrEqual(30000);
102
+ });
103
+ test('Truncation preserves original content up to limit', () => {
104
+ const content = 'ABCD'.repeat(25000); // 100000 chars, 25000 tokens
105
+ const result = truncateResponseIfNeeded(config, content);
106
+ // After truncation notice, should have first 40000 chars
107
+ const truncatedContent = result.split('=== END TRUNCATION NOTICE ===\n\n')[1];
108
+ expect(truncatedContent).toBe('ABCD'.repeat(10000)); // 40000 chars
109
+ });
110
+ test('Small content is not affected', () => {
111
+ const content = 'Hello, world!';
112
+ const result = truncateResponseIfNeeded(config, content);
113
+ expect(result).toBe(content);
114
+ });
115
+ test('Empty content is not affected', () => {
116
+ const content = '';
117
+ const result = truncateResponseIfNeeded(config, content);
118
+ expect(result).toBe('');
119
+ });
120
+ });
121
+ describe('Truncation - Edge Cases', () => {
122
+ let config;
123
+ beforeEach(() => {
124
+ config = {
125
+ maxTokens: 10000,
126
+ charsPerToken: 4,
127
+ enableLogging: false,
128
+ messagePrefix: 'RESPONSE TRUNCATED'
129
+ };
130
+ });
131
+ test('Handles very large content', () => {
132
+ const content = 'x'.repeat(1000000); // 1 million chars
133
+ const result = truncateResponseIfNeeded(config, content);
134
+ expect(result).toContain('=== RESPONSE TRUNCATED ===');
135
+ expect(result).toContain('Estimated tokens: 250000');
136
+ expect(result.length).toBeLessThan(50000); // Truncated + notice
137
+ });
138
+ test('Handles special characters', () => {
139
+ const content = '🚀'.repeat(25000); // Emoji might be 2 chars each in JS
140
+ const result = truncateResponseIfNeeded(config, content);
141
+ // Should still apply truncation logic based on string length
142
+ expect(result).toContain('=== RESPONSE TRUNCATED ===');
143
+ });
144
+ test('Handles newlines and whitespace', () => {
145
+ const content = 'line\n'.repeat(20000); // 100000 chars with newlines
146
+ const result = truncateResponseIfNeeded(config, content);
147
+ expect(result).toContain('=== RESPONSE TRUNCATED ===');
148
+ });
149
+ test('Truncation notice format is consistent', () => {
150
+ const content = 'x'.repeat(100000);
151
+ const result = truncateResponseIfNeeded(config, content);
152
+ // Verify notice structure
153
+ expect(result).toMatch(/=== RESPONSE TRUNCATED ===/);
154
+ expect(result).toMatch(/Estimated tokens: \d+ \(limit: \d+\)/);
155
+ expect(result).toMatch(/Response truncated to prevent context overflow\./);
156
+ expect(result).toMatch(/=== END TRUNCATION NOTICE ===/);
157
+ // Notice should be at the beginning
158
+ expect(result.indexOf('=== RESPONSE TRUNCATED ===')).toBe(0);
159
+ });
160
+ });
161
+ describe('Truncation - Different Token Limits', () => {
162
+ test('5000 token limit', () => {
163
+ const config = {
164
+ maxTokens: 5000,
165
+ charsPerToken: 4,
166
+ enableLogging: false
167
+ };
168
+ const content = 'x'.repeat(30000); // 7500 tokens
169
+ const result = truncateResponseIfNeeded(config, content);
170
+ expect(result).toContain('=== RESPONSE TRUNCATED ===');
171
+ expect(result).toContain('limit: 5000');
172
+ // Should truncate to 20000 chars (5000 * 4)
173
+ const truncatedContent = result.split('=== END TRUNCATION NOTICE ===\n\n')[1];
174
+ expect(truncatedContent.length).toBe(20000);
175
+ });
176
+ test('20000 token limit', () => {
177
+ const config = {
178
+ maxTokens: 20000,
179
+ charsPerToken: 4,
180
+ enableLogging: false
181
+ };
182
+ const content = 'x'.repeat(100000); // 25000 tokens
183
+ const result = truncateResponseIfNeeded(config, content);
184
+ expect(result).toContain('=== RESPONSE TRUNCATED ===');
185
+ expect(result).toContain('limit: 20000');
186
+ // Should truncate to 80000 chars (20000 * 4)
187
+ const truncatedContent = result.split('=== END TRUNCATION NOTICE ===\n\n')[1];
188
+ expect(truncatedContent.length).toBe(80000);
189
+ });
190
+ test('Very low token limit (100)', () => {
191
+ const config = {
192
+ maxTokens: 100,
193
+ charsPerToken: 4,
194
+ enableLogging: false
195
+ };
196
+ const content = 'x'.repeat(1000); // 250 tokens
197
+ const result = truncateResponseIfNeeded(config, content);
198
+ expect(result).toContain('=== RESPONSE TRUNCATED ===');
199
+ expect(result).toContain('limit: 100');
200
+ // Should truncate to 400 chars (100 * 4)
201
+ const truncatedContent = result.split('=== END TRUNCATION NOTICE ===\n\n')[1];
202
+ expect(truncatedContent.length).toBe(400);
203
+ });
204
+ });
@@ -104,6 +104,53 @@ const extractRawText = (response) => {
104
104
  }
105
105
  return null;
106
106
  };
107
+ /**
108
+ * Extract the exact content that will be written to file
109
+ * This is used for both character counting AND file writing to ensure consistency
110
+ * @returns Object with contentToWrite and parsedForSchema (if applicable)
111
+ */
112
+ const extractContentForFile = (responseData) => {
113
+ const rawText = extractRawText(responseData);
114
+ let contentToWrite;
115
+ let parsedForSchema = null;
116
+ if (rawText) {
117
+ try {
118
+ // Try to parse the raw text as JSON for prettier formatting
119
+ let jsonText = rawText;
120
+ // Extract JSON from common response patterns
121
+ const jsonMatch = rawText.match(/:\s*(\{.*\}|\[.*\])$/s);
122
+ if (jsonMatch) {
123
+ jsonText = jsonMatch[1];
124
+ }
125
+ const parsed = JSON.parse(jsonText);
126
+ parsedForSchema = parsed;
127
+ // Remove pagination-related fields before writing
128
+ const { pagination, has_more, next_page, previous_page, page, page_size, total_pages, ...cleanData } = parsed;
129
+ contentToWrite = JSON.stringify(cleanData, null, 2);
130
+ }
131
+ catch {
132
+ // If parsing fails, write the raw text directly
133
+ contentToWrite = rawText;
134
+ }
135
+ }
136
+ else {
137
+ // Fallback: extract from display content or stringify the entire response
138
+ if (responseData &&
139
+ typeof responseData === 'object' &&
140
+ 'content' in responseData &&
141
+ Array.isArray(responseData.content)) {
142
+ const textContent = responseData.content
143
+ .filter((item) => item.type === 'text')
144
+ .map((item) => item.text)
145
+ .join('\n');
146
+ contentToWrite = textContent;
147
+ }
148
+ else {
149
+ contentToWrite = JSON.stringify(responseData, null, 2);
150
+ }
151
+ }
152
+ return { contentToWrite, parsedForSchema };
153
+ };
107
154
  /**
108
155
  * Centralized response handler with file writing capability
109
156
  * @param config - File writer configuration
@@ -134,26 +181,11 @@ export async function handleToolResponse(config, toolName, args, responseData) {
134
181
  if (!config.enabled || !config.outputPath) {
135
182
  return responseData;
136
183
  }
184
+ // Extract the content that will be written to file
185
+ // This ensures we count the EXACT same content that will be written
186
+ const { contentToWrite, parsedForSchema } = extractContentForFile(responseData);
137
187
  // Check character count threshold - if response is too short, return directly
138
- const rawText = extractRawText(responseData);
139
- let contentLength = 0;
140
- if (rawText) {
141
- contentLength = rawText.length;
142
- }
143
- else if (responseData &&
144
- typeof responseData === 'object' &&
145
- 'content' in responseData &&
146
- Array.isArray(responseData.content)) {
147
- const textContent = responseData.content
148
- .filter((item) => item.type === 'text')
149
- .map((item) => item.text)
150
- .join('\n');
151
- contentLength = textContent.length;
152
- }
153
- else {
154
- contentLength = JSON.stringify(responseData).length;
155
- }
156
- // If content is shorter than threshold, return response directly instead of writing to file
188
+ const contentLength = contentToWrite.length;
157
189
  const minChars = config.minCharsForWrite ?? DEFAULT_MIN_CHARS;
158
190
  if (contentLength < minChars) {
159
191
  return responseData;
@@ -165,45 +197,7 @@ export async function handleToolResponse(config, toolName, args, responseData) {
165
197
  const filepath = path.join(config.outputPath, filename);
166
198
  // Ensure output directory exists
167
199
  await fs.mkdir(config.outputPath, { recursive: true });
168
- // Extract the actual data from MCP response format
169
- let contentToWrite;
170
- let parsedForSchema;
171
- if (rawText) {
172
- try {
173
- // Try to parse the raw text as JSON for prettier formatting
174
- let jsonText = rawText;
175
- // Extract JSON from common response patterns
176
- const jsonMatch = rawText.match(/:\s*(\{.*\}|\[.*\])$/s);
177
- if (jsonMatch) {
178
- jsonText = jsonMatch[1];
179
- }
180
- const parsed = JSON.parse(jsonText);
181
- parsedForSchema = parsed;
182
- // Remove pagination-related fields before writing
183
- const { pagination, has_more, next_page, previous_page, page, page_size, total_pages, ...cleanData } = parsed;
184
- contentToWrite = JSON.stringify(cleanData, null, 2);
185
- }
186
- catch {
187
- // If parsing fails, write the raw text directly
188
- contentToWrite = rawText;
189
- }
190
- }
191
- else {
192
- // Fallback: extract from display content or stringify the entire response
193
- if (responseData &&
194
- typeof responseData === 'object' &&
195
- 'content' in responseData &&
196
- Array.isArray(responseData.content)) {
197
- const textContent = responseData.content
198
- .filter((item) => item.type === 'text')
199
- .map((item) => item.text)
200
- .join('\n');
201
- contentToWrite = textContent;
202
- }
203
- else {
204
- contentToWrite = JSON.stringify(responseData, null, 2);
205
- }
206
- }
200
+ // Write the exact content we counted
207
201
  await fs.writeFile(filepath, contentToWrite);
208
202
  // Try to generate schema if we have valid JSON
209
203
  let schemaInfo = '';
package/dist/index.js CHANGED
@@ -263,56 +263,71 @@ async function main() {
263
263
  console.error(`[mcp-proxy] Tool call: ${toolName}`);
264
264
  }
265
265
  try {
266
+ let result;
266
267
  // Handle JQ tool locally (if enabled)
267
268
  if (toolName === 'execute_jq_query' && jqTool) {
268
269
  if (ENABLE_LOGGING) {
269
270
  console.error('[mcp-proxy] Executing JQ tool locally');
270
271
  }
271
- return await jqTool.handler({
272
+ result = await jqTool.handler({
272
273
  params: { arguments: toolArgs }
273
274
  });
274
275
  }
275
- // Forward all other tools to child MCP (if child exists)
276
- if (!childClient) {
277
- return {
278
- content: [{
279
- type: 'text',
280
- text: `Error: Tool ${toolName} not available in standalone mode (no child MCP)`
281
- }],
282
- isError: true
283
- };
284
- }
285
- if (ENABLE_LOGGING) {
286
- console.error(`[mcp-proxy] Forwarding to child MCP: ${toolName}`);
276
+ else {
277
+ // Forward all other tools to child MCP (if child exists)
278
+ if (!childClient) {
279
+ return {
280
+ content: [{
281
+ type: 'text',
282
+ text: `Error: Tool ${toolName} not available in standalone mode (no child MCP)`
283
+ }],
284
+ isError: true
285
+ };
286
+ }
287
+ if (ENABLE_LOGGING) {
288
+ console.error(`[mcp-proxy] Forwarding to child MCP: ${toolName}`);
289
+ }
290
+ result = await childClient.callTool({
291
+ name: toolName,
292
+ arguments: toolArgs
293
+ });
287
294
  }
288
- const result = await childClient.callTool({
289
- name: toolName,
290
- arguments: toolArgs
291
- });
292
- // Apply truncation and file writing to text responses
295
+ // Apply file writing and truncation to text responses
293
296
  if (result.content && Array.isArray(result.content) && result.content.length > 0) {
294
297
  for (let i = 0; i < result.content.length; i++) {
295
298
  const item = result.content[i];
296
299
  if (item.type === 'text' && typeof item.text === 'string') {
300
+ const originalText = item.text;
297
301
  const originalLength = item.text.length;
298
- // Step 1: Apply truncation
299
- item.text = truncateResponseIfNeeded(truncationConfig, item.text);
300
- if (item.text.length < originalLength && ENABLE_LOGGING) {
301
- console.error(`[mcp-proxy] Truncated response: ${originalLength} → ${item.text.length} chars`);
302
- }
303
- // Step 2: Apply file writing (if enabled)
302
+ let fileWasWritten = false;
303
+ // Step 1: Try file writing first (if enabled)
304
+ // This writes the FULL original data to file
304
305
  if (fileWriterConfig.enabled) {
305
306
  const fileResult = await fileWriter.handleResponse(toolName, toolArgs, {
306
307
  content: [{ type: 'text', text: item.text }]
307
308
  });
309
+ // Check if file was actually written (file reference returned)
308
310
  if (fileResult && fileResult.content && Array.isArray(fileResult.content) &&
309
311
  fileResult.content.length > 0 && fileResult.content[0].type === 'text') {
310
- item.text = fileResult.content[0].text;
311
- if (ENABLE_LOGGING) {
312
- console.error(`[mcp-proxy] File writing applied for ${toolName}`);
312
+ const resultText = fileResult.content[0].text;
313
+ // File reference contains "📄 File:" - this means file was written
314
+ if (resultText.includes('📄 File:')) {
315
+ item.text = resultText;
316
+ fileWasWritten = true;
317
+ if (ENABLE_LOGGING) {
318
+ console.error(`[mcp-proxy] File writing applied for ${toolName} (${originalLength} chars written to file)`);
319
+ }
313
320
  }
314
321
  }
315
322
  }
323
+ // Step 2: Apply truncation only if file was NOT written
324
+ // (File references are small and don't need truncation)
325
+ if (!fileWasWritten) {
326
+ item.text = truncateResponseIfNeeded(truncationConfig, item.text);
327
+ if (item.text.length < originalLength && ENABLE_LOGGING) {
328
+ console.error(`[mcp-proxy] Truncated response: ${originalLength} → ${item.text.length} chars`);
329
+ }
330
+ }
316
331
  }
317
332
  }
318
333
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anyshift/mcp-proxy",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Generic MCP proxy that adds truncation, file writing, and JQ capabilities to any MCP server",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,7 +17,11 @@
17
17
  "zod": "^3.24.2"
18
18
  },
19
19
  "devDependencies": {
20
+ "@jest/globals": "^30.2.0",
21
+ "@types/jest": "^30.0.0",
20
22
  "@types/node": "^22.0.0",
23
+ "jest": "^30.2.0",
24
+ "ts-jest": "^29.4.5",
21
25
  "tsx": "^4.7.0",
22
26
  "typescript": "^5.3.3"
23
27
  },
@@ -27,6 +31,11 @@
27
31
  "scripts": {
28
32
  "build": "tsc",
29
33
  "start": "node dist/index.js",
30
- "dev": "tsx src/index.ts"
34
+ "dev": "tsx src/index.ts",
35
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
36
+ "test:unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=unit",
37
+ "test:integration": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=integration",
38
+ "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
39
+ "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage"
31
40
  }
32
41
  }