@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.
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { generateCompactFilename } from '../utils/filename.js';
4
- import { analyzeJsonSchema, extractNullableFields } from './schema.js';
4
+ import { generateQueryAssistSchema } from './schema.js';
5
5
  // Default minimum character count to trigger file writing
6
6
  const DEFAULT_MIN_CHARS = 1000;
7
7
  /**
@@ -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
@@ -113,8 +160,8 @@ const extractRawText = (response) => {
113
160
  * @returns Either the original response or a file reference response
114
161
  */
115
162
  export async function handleToolResponse(config, toolName, args, responseData) {
116
- // JQ query tool should always return directly to AI (never write to file)
117
- if (toolName === 'execute_jq_query') {
163
+ // Some tools should always return directly to AI (never write to file)
164
+ if (toolName === 'execute_jq_query' || toolName === 'get_label_schema') {
118
165
  return responseData;
119
166
  }
120
167
  // If there's an error, return proper MCP error response (never write errors to file)
@@ -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,106 +197,30 @@ 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
- // Try to generate schema if we have valid JSON
202
+ // Generate query-assist schema if we have valid JSON
209
203
  let schemaInfo = '';
210
- let quickReference = '';
211
204
  if (parsedForSchema) {
212
205
  // Use the clean data (without pagination) for schema analysis
213
206
  const { pagination, has_more, next_page, previous_page, page, page_size, total_pages, ...cleanData } = parsedForSchema;
214
- const schema = analyzeJsonSchema(cleanData);
215
- const nullFields = extractNullableFields(schema);
216
- const schemaObj = schema;
217
- // Build quick reference section
218
- quickReference += `\n\n🔍 UNDERSTAND THIS SCHEMA BEFORE WRITING JQ QUERIES:\n`;
219
- // Structure hints
220
- if (schemaObj._keysAreNumeric) {
221
- quickReference += ` • Structure: Object with numeric keys ("0", "1", ...) - use .["0"]\n`;
222
- }
223
- else if (schemaObj.type === 'array') {
224
- quickReference += ` • Structure: Array with ${schemaObj.length} items\n`;
225
- }
226
- else if (schemaObj.type === 'object' && schemaObj.properties) {
227
- const props = schemaObj.properties;
228
- const keys = Object.keys(props).slice(0, 5).join(', ');
229
- quickReference += ` • Structure: Object with keys: ${keys}\n`;
230
- }
231
- // Always null fields
232
- if (nullFields.alwaysNull.length > 0) {
233
- const fieldList = nullFields.alwaysNull.slice(0, 5).join(', ');
234
- const more = nullFields.alwaysNull.length > 5
235
- ? ` (+${nullFields.alwaysNull.length - 5} more)`
236
- : '';
237
- quickReference += ` • Always null: ${fieldList}${more}\n`;
238
- }
239
- // Nullable fields
240
- if (nullFields.nullable.length > 0) {
241
- const fieldList = nullFields.nullable.slice(0, 5).join(', ');
242
- const more = nullFields.nullable.length > 5
243
- ? ` (+${nullFields.nullable.length - 5} more)`
244
- : '';
245
- quickReference += ` • Sometimes null: ${fieldList}${more}\n`;
246
- }
247
- // Suggest exploratory queries
248
- if (schemaObj._keysAreNumeric) {
249
- quickReference += ` • Explore: keys, .["0"] | keys, .["0"]\n`;
250
- }
251
- else if (schemaObj.type === 'array' && schemaObj.length > 0) {
252
- quickReference += ` • Explore: length, .[0] | keys, .[0]\n`;
253
- }
254
- else {
255
- quickReference += ` • Explore: keys, type\n`;
256
- }
257
- // Full schema
258
- schemaInfo = `\n\nFull JSON Schema:\n${JSON.stringify(schema, null, 2)}`;
207
+ // Generate compact query-assist schema using config values
208
+ // Pass contentLength to avoid re-stringifying large payloads
209
+ schemaInfo = `\n\n${generateQueryAssistSchema(cleanData, {
210
+ maxDepth: config.schemaMaxDepth ?? 2,
211
+ maxPaths: config.schemaMaxPaths ?? 20,
212
+ maxKeys: config.schemaMaxKeys ?? 50,
213
+ dataSize: contentLength
214
+ })}`;
259
215
  }
260
216
  // Count lines in the content
261
217
  const lineCount = contentToWrite.split('\n').length;
262
- // Return success message with file path, size, lines, quick reference, and schema
218
+ // Return success message with file path, size, lines, and schema
263
219
  return {
264
220
  content: [
265
221
  {
266
222
  type: 'text',
267
- text: `📄 File: ${filepath}\nSize: ${contentToWrite.length} characters | Lines: ${lineCount}${quickReference}${schemaInfo}`,
223
+ text: `📄 File: ${filepath}\nSize: ${contentToWrite.length} characters | Lines: ${lineCount}${schemaInfo}`,
268
224
  },
269
225
  ],
270
226
  };
package/dist/index.js CHANGED
@@ -95,6 +95,24 @@ const ENABLE_JQ = process.env.MCP_PROXY_ENABLE_JQ !== 'false'; // default true
95
95
  * Timeout in milliseconds for JQ query execution
96
96
  */
97
97
  const JQ_TIMEOUT_MS = parseInt(process.env.MCP_PROXY_JQ_TIMEOUT_MS || '30000');
98
+ /**
99
+ * MCP_PROXY_SCHEMA_MAX_DEPTH (OPTIONAL, default: 3)
100
+ * Maximum depth to traverse when generating query-assist schemas
101
+ * Deeper structures will show exploration prompts instead
102
+ */
103
+ const SCHEMA_MAX_DEPTH = parseInt(process.env.MCP_PROXY_SCHEMA_MAX_DEPTH || '3');
104
+ /**
105
+ * MCP_PROXY_SCHEMA_MAX_PATHS (OPTIONAL, default: 20)
106
+ * Maximum number of paths to show in query-assist schemas
107
+ * Prioritizes non-null, shallow, and interesting paths
108
+ */
109
+ const SCHEMA_MAX_PATHS = parseInt(process.env.MCP_PROXY_SCHEMA_MAX_PATHS || '20');
110
+ /**
111
+ * MCP_PROXY_SCHEMA_MAX_KEYS (OPTIONAL, default: 50)
112
+ * Maximum number of object keys to analyze per object
113
+ * Objects with more keys will show a key limit warning
114
+ */
115
+ const SCHEMA_MAX_KEYS = parseInt(process.env.MCP_PROXY_SCHEMA_MAX_KEYS || '50');
98
116
  /**
99
117
  * MCP_PROXY_ENABLE_LOGGING (OPTIONAL, default: false)
100
118
  * Enable debug logging for the proxy
@@ -228,7 +246,10 @@ async function main() {
228
246
  enabled: WRITE_TO_FILE,
229
247
  outputPath: OUTPUT_PATH,
230
248
  minCharsForWrite: MIN_CHARS_FOR_WRITE,
231
- toolAbbreviations: {} // No service-specific abbreviations (generic proxy)
249
+ toolAbbreviations: {}, // No service-specific abbreviations (generic proxy)
250
+ schemaMaxDepth: SCHEMA_MAX_DEPTH,
251
+ schemaMaxPaths: SCHEMA_MAX_PATHS,
252
+ schemaMaxKeys: SCHEMA_MAX_KEYS
232
253
  };
233
254
  const fileWriter = createFileWriter(fileWriterConfig);
234
255
  // JQ tool configuration
@@ -263,56 +284,71 @@ async function main() {
263
284
  console.error(`[mcp-proxy] Tool call: ${toolName}`);
264
285
  }
265
286
  try {
287
+ let result;
266
288
  // Handle JQ tool locally (if enabled)
267
289
  if (toolName === 'execute_jq_query' && jqTool) {
268
290
  if (ENABLE_LOGGING) {
269
291
  console.error('[mcp-proxy] Executing JQ tool locally');
270
292
  }
271
- return await jqTool.handler({
293
+ result = await jqTool.handler({
272
294
  params: { arguments: toolArgs }
273
295
  });
274
296
  }
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}`);
297
+ else {
298
+ // Forward all other tools to child MCP (if child exists)
299
+ if (!childClient) {
300
+ return {
301
+ content: [{
302
+ type: 'text',
303
+ text: `Error: Tool ${toolName} not available in standalone mode (no child MCP)`
304
+ }],
305
+ isError: true
306
+ };
307
+ }
308
+ if (ENABLE_LOGGING) {
309
+ console.error(`[mcp-proxy] Forwarding to child MCP: ${toolName}`);
310
+ }
311
+ result = await childClient.callTool({
312
+ name: toolName,
313
+ arguments: toolArgs
314
+ });
287
315
  }
288
- const result = await childClient.callTool({
289
- name: toolName,
290
- arguments: toolArgs
291
- });
292
- // Apply truncation and file writing to text responses
316
+ // Apply file writing and truncation to text responses
293
317
  if (result.content && Array.isArray(result.content) && result.content.length > 0) {
294
318
  for (let i = 0; i < result.content.length; i++) {
295
319
  const item = result.content[i];
296
320
  if (item.type === 'text' && typeof item.text === 'string') {
321
+ const originalText = item.text;
297
322
  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)
323
+ let fileWasWritten = false;
324
+ // Step 1: Try file writing first (if enabled)
325
+ // This writes the FULL original data to file
304
326
  if (fileWriterConfig.enabled) {
305
327
  const fileResult = await fileWriter.handleResponse(toolName, toolArgs, {
306
328
  content: [{ type: 'text', text: item.text }]
307
329
  });
330
+ // Check if file was actually written (file reference returned)
308
331
  if (fileResult && fileResult.content && Array.isArray(fileResult.content) &&
309
332
  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}`);
333
+ const resultText = fileResult.content[0].text;
334
+ // File reference contains "📄 File:" - this means file was written
335
+ if (resultText.includes('📄 File:')) {
336
+ item.text = resultText;
337
+ fileWasWritten = true;
338
+ if (ENABLE_LOGGING) {
339
+ console.error(`[mcp-proxy] File writing applied for ${toolName} (${originalLength} chars written to file)`);
340
+ }
313
341
  }
314
342
  }
315
343
  }
344
+ // Step 2: Apply truncation only if file was NOT written
345
+ // (File references are small and don't need truncation)
346
+ if (!fileWasWritten) {
347
+ item.text = truncateResponseIfNeeded(truncationConfig, item.text);
348
+ if (item.text.length < originalLength && ENABLE_LOGGING) {
349
+ console.error(`[mcp-proxy] Truncated response: ${originalLength} → ${item.text.length} chars`);
350
+ }
351
+ }
316
352
  }
317
353
  }
318
354
  }
package/dist/jq/tool.js CHANGED
@@ -73,6 +73,21 @@ export const JQ_TOOL_DEFINITION = {
73
73
  '\n2. **Incremental filtering**: Start with no filters, add conditions one by one' +
74
74
  '\n3. **Alternative null handling**: Use `// empty`, `select(. != null)`, or `try ... catch`' +
75
75
  '\n4. **Simplified queries**: Break complex queries into smaller, testable parts' +
76
+ '\n\n## COMMON JQ PATTERNS (Quick Reference):' +
77
+ '\n- **List all keys**: `keys` or `.[] | keys` (for nested)' +
78
+ '\n- **Check type**: `type` or `.field | type`' +
79
+ '\n- **Array length**: `.items | length` or `[.[]] | length`' +
80
+ '\n- **Filter array**: `.items[] | select(.price > 100)` or `select(.field == "value")`' +
81
+ '\n- **Extract field**: `.items[].id` or `.[] | .field`' +
82
+ '\n- **Get unique values**: `.items[].type | unique` or `[.[].field] | unique`' +
83
+ '\n- **Find nulls**: `.items[] | select(.field == null)` or `select(.field)` (non-null only)' +
84
+ '\n- **Count occurrences**: `group_by(.type) | map({type: .[0].type, count: length})`' +
85
+ '\n- **Sort**: `sort_by(.price)` or `sort_by(.price) | reverse` (descending)' +
86
+ '\n- **Map transform**: `[.[] | {id: .id, name: .name}]` (extract subset of fields)' +
87
+ '\n- **First N items**: `.[:5]` (array slice)' +
88
+ '\n- **Limit stream**: `limit(10; .[])` (stream processing)' +
89
+ '\n- **Default values**: `.field // "default"` or `.field // empty`' +
90
+ '\n- **Conditional**: `if .price > 100 then "expensive" else "cheap" end`' +
76
91
  '\n\n## COMPREHENSIVE EXAMPLES:' +
77
92
  '\n**Debugging sequence for Cypher results**:' +
78
93
  '\n- `keys` → ["0", "1", "2", ...] (shows object structure)' +
@@ -10,6 +10,12 @@ export interface FileWriterConfig {
10
10
  minCharsForWrite?: number;
11
11
  /** Custom abbreviations for tool names in filenames */
12
12
  toolAbbreviations?: Record<string, string>;
13
+ /** Maximum depth for schema generation (default: 2) */
14
+ schemaMaxDepth?: number;
15
+ /** Maximum paths to show in schema (default: 20) */
16
+ schemaMaxPaths?: number;
17
+ /** Maximum keys to analyze per object (default: 50) */
18
+ schemaMaxKeys?: number;
13
19
  }
14
20
  /**
15
21
  * Configuration for the JQ tool
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.2",
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
  }