@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.
- package/dist/__tests__/helpers/testUtils.d.ts +127 -0
- package/dist/__tests__/helpers/testUtils.js +122 -0
- package/dist/__tests__/unit/truncation.test.d.ts +5 -0
- package/dist/__tests__/unit/truncation.test.js +204 -0
- package/dist/fileWriter/writer.js +52 -58
- package/dist/index.js +42 -27
- package/package.json +11 -2
|
@@ -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,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
|
|
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
|
-
//
|
|
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
|
-
|
|
272
|
+
result = await jqTool.handler({
|
|
272
273
|
params: { arguments: toolArgs }
|
|
273
274
|
});
|
|
274
275
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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.
|
|
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
|
}
|