@anyshift/mcp-proxy 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -133,39 +133,82 @@ npx @anyshift/mcp-proxy
133
133
 
134
134
  ## How It Works
135
135
 
136
+ ```mermaid
137
+ graph TB
138
+ AI[🤖 AI Agent<br/>Claude]
139
+
140
+ AI -->|MCP Protocol| Proxy
141
+
142
+ subgraph Proxy["@anyshift/mcp-proxy"]
143
+ Start[Receive tool call]
144
+ CheckJQ{JQ tool?}
145
+ ExecuteJQ[Execute JQ locally]
146
+ Forward[Forward to child MCP]
147
+ GetResponse[Get response from child]
148
+ CheckSize{Size ≥ 1000 chars?}
149
+ WriteFile[📄 Write FULL data to file]
150
+ ReturnFile[Return file reference]
151
+ CheckTrunc{Size > 40K chars?}
152
+ Truncate[Truncate with notice]
153
+ ReturnDirect[Return response]
154
+
155
+ Start --> CheckJQ
156
+ CheckJQ -->|Yes| ExecuteJQ
157
+ CheckJQ -->|No| Forward
158
+ Forward --> GetResponse
159
+ ExecuteJQ --> CheckTrunc
160
+ GetResponse --> CheckSize
161
+ CheckSize -->|Yes| WriteFile
162
+ WriteFile --> ReturnFile
163
+ CheckSize -->|No| CheckTrunc
164
+ CheckTrunc -->|Yes| Truncate
165
+ CheckTrunc -->|No| ReturnDirect
166
+
167
+ ReturnFile -.->|📄 Small reference| AI
168
+ Truncate -.->|Truncated text| AI
169
+ ReturnDirect -.->|Full response| AI
170
+ end
171
+
172
+ Proxy -->|stdio + env vars| Child[Child MCP<br/>anyshift/datadog/grafana]
173
+ Child -.->|Response| Proxy
174
+
175
+ style AI fill:#e1f5ff
176
+ style Proxy fill:#fff4e1
177
+ style Child fill:#e8f5e9
178
+ style WriteFile fill:#c8e6c9
179
+ style ReturnFile fill:#c8e6c9
136
180
  ```
137
- ┌─────────────┐
138
- │ AI Agent │
139
- └──────┬──────┘
140
- MCP Protocol (stdio)
141
-
142
- ┌─────────────────────────────────┐
143
- │ @anyshift/mcp-proxy │
144
- │ │
145
- 1. Spawns child MCP │
146
- │ with pass-through env vars │
147
- │ │
148
- │ 2. Discovers child tools │
149
- │ + adds JQ tool │
150
- │ │
151
- │ 3. Forwards tool calls │
152
- │ │
153
- │ 4. Applies truncation │
154
- │ if response > MAX_TOKENS │
155
- │ │
156
- │ 5. Writes to file │
157
- │ if response > MIN_CHARS │
158
- │ │
159
- │ 6. Returns modified response │
160
- │ to AI agent │
161
- └────────────┬────────────────────┘
162
- │ Child process (stdio)
163
-
164
- ┌──────────────┐
165
- │ Child MCP │ (mcp-grafana, custom-mcp, etc.)
166
- │ Server │ Gets env vars WITHOUT MCP_PROXY_ prefix
167
- └──────────────┘
181
+
182
+ ### Response Handling Examples
183
+
184
+ **Small responses (< 1,000 chars):**
185
+ ```
186
+ Child: 500 chars → Proxy: Return directly → AI: 500 chars ✓
187
+ ```
188
+
189
+ **Medium responses (1,000 - 40,000 chars):**
190
+ ```
191
+ Child: 5,000 chars → Proxy: Write to file → AI: "📄 File: path/to/file.json" ✓
192
+ ```
193
+
194
+ **Large responses (> 40,000 chars):**
195
+ ```
196
+ Child: 100,000 chars → Proxy: Write FULL 100K to file → AI: "📄 File: ..." ✓
197
+ Note: File contains complete data, not truncated!
198
+ ```
199
+
200
+ **JQ tool queries:**
168
201
  ```
202
+ AI: JQ query → Proxy: Execute locally → AI: Result (truncated if > 40K) ✓
203
+ ```
204
+
205
+ ### Key Design Principle
206
+
207
+ **File writing happens BEFORE truncation.** This ensures:
208
+ - Files always contain complete, untruncated data
209
+ - Large responses are accessible via file references
210
+ - AI receives small, manageable responses
211
+ - No data loss due to context limits
169
212
 
170
213
  ## Integration Examples
171
214
 
@@ -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 @@
1
+ export {};
@@ -0,0 +1,267 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { generateQueryAssistSchema } from '../../fileWriter/schema.js';
3
+ describe('Query-Assist Schema Generator', () => {
4
+ describe('Basic Structure Detection', () => {
5
+ it('should detect simple object structure', () => {
6
+ const data = {
7
+ id: '123',
8
+ name: 'Test',
9
+ count: 42
10
+ };
11
+ const schema = generateQueryAssistSchema(data, { maxDepth: 2, maxPaths: 20 });
12
+ expect(schema).toContain('📊 STRUCTURE GUIDE');
13
+ expect(schema).toContain('.id');
14
+ expect(schema).toContain('.name');
15
+ expect(schema).toContain('.count');
16
+ expect(schema).toContain('string');
17
+ expect(schema).toContain('number');
18
+ });
19
+ it('should detect array structure', () => {
20
+ const data = {
21
+ items: [
22
+ { id: '1', price: 100 },
23
+ { id: '2', price: 200 }
24
+ ]
25
+ };
26
+ // With maxDepth=3, we can see array item fields
27
+ const schema = generateQueryAssistSchema(data, { maxDepth: 3, maxPaths: 20 });
28
+ expect(schema).toContain('.items');
29
+ expect(schema).toContain('array[2]');
30
+ expect(schema).toContain('.items[].id');
31
+ expect(schema).toContain('.items[].price');
32
+ });
33
+ it('should detect nested object structure', () => {
34
+ const data = {
35
+ user: {
36
+ profile: {
37
+ name: 'Alice',
38
+ age: 30
39
+ }
40
+ }
41
+ };
42
+ const schema = generateQueryAssistSchema(data, { maxDepth: 2, maxPaths: 20 });
43
+ expect(schema).toContain('.user');
44
+ expect(schema).toContain('.user.profile');
45
+ expect(schema).toContain('object');
46
+ });
47
+ });
48
+ describe('Depth Limiting', () => {
49
+ it('should stop at max depth and show warning', () => {
50
+ const data = {
51
+ level1: {
52
+ level2: {
53
+ level3: {
54
+ level4: 'too deep'
55
+ }
56
+ }
57
+ }
58
+ };
59
+ const schema = generateQueryAssistSchema(data, { maxDepth: 2, maxPaths: 20 });
60
+ // Should show paths up to depth 2
61
+ expect(schema).toContain('.level1.level2');
62
+ // Should show depth limit warning in consolidated format
63
+ expect(schema).toContain('⚠️ Limits: DEPTH (max: 2)');
64
+ // level3 can appear in exploration prompts, but not as a path entry
65
+ // Check that level3 is not shown as a separate path line
66
+ const lines = schema.split('\n');
67
+ const pathLines = lines.filter(l => l.includes('→') && l.includes('.level'));
68
+ expect(pathLines.some(l => l.includes('.level1.level2.level3') && l.includes(' → '))).toBe(false);
69
+ });
70
+ it('should show exploration prompts when depth limit hit', () => {
71
+ const data = {
72
+ deep: {
73
+ nested: {
74
+ value: 'hidden'
75
+ }
76
+ }
77
+ };
78
+ const schema = generateQueryAssistSchema(data, { maxDepth: 1, maxPaths: 20 });
79
+ expect(schema).toContain('💡 EXPLORATION GUIDE');
80
+ expect(schema).toContain('⚠️ Limits: DEPTH (max: 1)');
81
+ expect(schema).toContain('View keys:');
82
+ expect(schema).toContain('Check type:');
83
+ });
84
+ });
85
+ describe('Key Limiting', () => {
86
+ it('should limit keys per object and show warning', () => {
87
+ const data = {};
88
+ for (let i = 0; i < 100; i++) {
89
+ data[`field_${i}`] = i;
90
+ }
91
+ const schema = generateQueryAssistSchema(data, { maxDepth: 2, maxPaths: 100, maxKeys: 20 });
92
+ expect(schema).toContain('💡 EXPLORATION GUIDE');
93
+ expect(schema).toContain('⚠️ Limits: KEYS (20 shown, 80 more)');
94
+ });
95
+ it('should show key exploration prompts when limit hit', () => {
96
+ const data = {};
97
+ for (let i = 0; i < 60; i++) {
98
+ data[`key${i}`] = `value${i}`;
99
+ }
100
+ const schema = generateQueryAssistSchema(data, { maxDepth: 2, maxPaths: 100, maxKeys: 30 });
101
+ expect(schema).toContain('💡 EXPLORATION GUIDE');
102
+ expect(schema).toContain('⚠️ Limits: KEYS (30 shown, 30 more)');
103
+ expect(schema).toContain('View keys:');
104
+ expect(schema).toContain('Count items:');
105
+ });
106
+ });
107
+ describe('Path Limiting', () => {
108
+ it('should limit total paths shown and prioritize important ones', () => {
109
+ // Create data with many fields to exceed path limit
110
+ const data = {
111
+ id: '123',
112
+ name: 'Test'
113
+ };
114
+ // Add many top-level fields
115
+ for (let i = 0; i < 20; i++) {
116
+ data[`field${i}`] = { nested: `value${i}` };
117
+ }
118
+ const schema = generateQueryAssistSchema(data, { maxDepth: 2, maxPaths: 10, maxKeys: 50 });
119
+ // Should show path limit warning in consolidated format
120
+ expect(schema).toContain('💡 EXPLORATION GUIDE');
121
+ expect(schema).toContain('PATHS (10 of');
122
+ // Should show some paths (prioritizes objects over primitives)
123
+ expect(schema).toContain('.field');
124
+ // Count number of path lines shown (should be exactly 10)
125
+ const pathLines = schema.split('\n').filter(l => l.includes(' → '));
126
+ expect(pathLines.length).toBeLessThanOrEqual(11); // 10 paths + root = 11
127
+ });
128
+ it('should show path exploration prompts when limit hit', () => {
129
+ const largeData = {};
130
+ for (let i = 0; i < 30; i++) {
131
+ largeData[`field${i}`] = { nested: 'value' };
132
+ }
133
+ const schema = generateQueryAssistSchema(largeData, { maxDepth: 2, maxPaths: 10, maxKeys: 50 });
134
+ expect(schema).toContain('💡 EXPLORATION GUIDE');
135
+ expect(schema).toContain('PATHS (10 of');
136
+ expect(schema).toContain('View keys:');
137
+ expect(schema).toContain('List all paths:');
138
+ });
139
+ });
140
+ describe('Mixed Schema Detection', () => {
141
+ it('should detect heterogeneous arrays', () => {
142
+ const data = {
143
+ items: [
144
+ { type: 'book', pages: 200 },
145
+ { type: 'video', duration: 120 },
146
+ { type: 'audio', length: 180 }
147
+ ]
148
+ };
149
+ const schema = generateQueryAssistSchema(data, { maxDepth: 2, maxPaths: 20 });
150
+ expect(schema).toContain('💡 EXPLORATION GUIDE');
151
+ expect(schema).toContain('MIXED SCHEMAS');
152
+ });
153
+ it('should show mixed schema exploration prompts', () => {
154
+ const data = {
155
+ data: [
156
+ { a: 1 },
157
+ { b: 2 },
158
+ { c: 3 }
159
+ ]
160
+ };
161
+ const schema = generateQueryAssistSchema(data, { maxDepth: 2, maxPaths: 20 });
162
+ expect(schema).toContain('💡 EXPLORATION GUIDE');
163
+ expect(schema).toContain('MIXED SCHEMAS');
164
+ expect(schema).toContain('Check variance:');
165
+ });
166
+ });
167
+ describe('Numeric Keys Detection', () => {
168
+ it('should detect numeric string keys and show representative structure', () => {
169
+ const data = {
170
+ '0': { name: 'Alice', age: 30 },
171
+ '1': { name: 'Bob', age: 25 },
172
+ '2': { name: 'Charlie', age: 35 }
173
+ };
174
+ const schema = generateQueryAssistSchema(data, { maxDepth: 3, maxPaths: 20 });
175
+ // Should detect numeric keys
176
+ expect(schema).toContain('object (numeric keys)');
177
+ expect(schema).toContain('(3 keys)');
178
+ // Should show representative structure with .[<idx>] notation
179
+ expect(schema).toContain('.[<idx>]');
180
+ // Should show nested structure of representative item
181
+ expect(schema).toContain('.[<idx>].name');
182
+ expect(schema).toContain('.[<idx>].age');
183
+ // Should show explanation note in exploration guide
184
+ expect(schema).toContain('💡 EXPLORATION GUIDE');
185
+ expect(schema).toContain('NUMERIC KEYS:');
186
+ expect(schema).toContain('.["0"], .["1"]');
187
+ expect(schema).toContain('.[0], .[1]');
188
+ // Should NOT enumerate individual keys
189
+ expect(schema).not.toContain('.0 ');
190
+ expect(schema).not.toContain('.1 ');
191
+ expect(schema).not.toContain('.2 ');
192
+ });
193
+ });
194
+ describe('Nullable Fields', () => {
195
+ it('should detect null values', () => {
196
+ const data = {
197
+ present: 'value',
198
+ missing: null,
199
+ empty: ''
200
+ };
201
+ const schema = generateQueryAssistSchema(data, { maxDepth: 2, maxPaths: 20 });
202
+ expect(schema).toContain('.missing');
203
+ expect(schema).toContain('null');
204
+ expect(schema).toContain('(nullable)');
205
+ });
206
+ });
207
+ describe('Schema Compactness', () => {
208
+ it('should not include common JQ patterns (moved to tool description)', () => {
209
+ const data = { simple: 'data' };
210
+ const schema = generateQueryAssistSchema(data, { maxDepth: 2, maxPaths: 20 });
211
+ // Common JQ patterns are now in the JQ tool description, not in every file reference
212
+ expect(schema).not.toContain('COMMON JQ PATTERNS:');
213
+ expect(schema).not.toContain('List all keys:');
214
+ // Schema should still contain the structure guide header
215
+ expect(schema).toContain('📊 STRUCTURE GUIDE');
216
+ });
217
+ });
218
+ describe('Size Constraints', () => {
219
+ it('should generate compact output for large structures', () => {
220
+ // Create a large nested structure
221
+ const data = {};
222
+ for (let i = 0; i < 100; i++) {
223
+ data[`key${i}`] = {
224
+ nested: {
225
+ deep: {
226
+ value: i
227
+ }
228
+ }
229
+ };
230
+ }
231
+ const schema = generateQueryAssistSchema(data, {
232
+ maxDepth: 2,
233
+ maxPaths: 20,
234
+ maxKeys: 50
235
+ });
236
+ // Schema should be compact (under 5KB as designed)
237
+ expect(schema.length).toBeLessThan(5000);
238
+ // Should contain warnings about limits
239
+ expect(schema).toContain('⚠️');
240
+ });
241
+ });
242
+ describe('Empty Data Handling', () => {
243
+ it('should handle empty object', () => {
244
+ const data = {};
245
+ const schema = generateQueryAssistSchema(data, { maxDepth: 2, maxPaths: 20 });
246
+ expect(schema).toContain('📊 STRUCTURE GUIDE');
247
+ expect(schema).toContain('(root)');
248
+ expect(schema).toContain('object');
249
+ expect(schema).toContain('(0 keys)');
250
+ });
251
+ it('should handle empty array', () => {
252
+ const data = { items: [] };
253
+ const schema = generateQueryAssistSchema(data, { maxDepth: 2, maxPaths: 20 });
254
+ expect(schema).toContain('.items');
255
+ expect(schema).toContain('array[0]');
256
+ });
257
+ });
258
+ describe('Data Size Reporting', () => {
259
+ it('should report data size in characters', () => {
260
+ const data = { test: 'value' };
261
+ const schema = generateQueryAssistSchema(data, { maxDepth: 2, maxPaths: 20 });
262
+ expect(schema).toContain('Size:');
263
+ expect(schema).toContain('characters');
264
+ expect(schema).toMatch(/Size: \d+(,\d{3})* characters/);
265
+ });
266
+ });
267
+ });
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Unit tests for truncation module
3
+ * Tests token estimation and truncation logic
4
+ */
5
+ export {};