@anyshift/mcp-proxy 0.6.7 → 0.6.8

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,5 +1,6 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
+ import Papa from 'papaparse';
3
4
  import { generateToolId } from '../utils/filename.js';
4
5
  import { generateQueryAssistSchema, generateJsonlQueryAssistSchema } from './schema.js';
5
6
  import { parseDynatraceDqlResponse, isDynatraceDqlTool, } from './dynatrace.js';
@@ -10,18 +11,85 @@ const FILE_WRITTEN_MESSAGE = `To read this file, use the "mcp__file_query__execu
10
11
 
11
12
  IMPORTANT for supporting facts: This tool_id CANNOT be used as the proxy_tool_id in your output's supporting_facts evidence. You must read the file using one of the tools above and use THAT tool's tool_id as the proxy_tool_id to support facts in your output.`;
12
13
  /**
13
- * Detect whether content is JSON, JSONL, or plain text
14
+ * Detect if content is CSV and convert to JSONL using papaparse
15
+ * Handles: auto-delimiter detection, header detection, quoted fields, etc.
16
+ */
17
+ function detectAndConvertCsv(content) {
18
+ const trimmed = content.trim();
19
+ // Skip if empty
20
+ if (!trimmed) {
21
+ return { isCsv: false, hasHeaders: false };
22
+ }
23
+ // Skip if it looks like JSON
24
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
25
+ return { isCsv: false, hasHeaders: false };
26
+ }
27
+ // First, try parsing WITH headers
28
+ const resultWithHeaders = Papa.parse(trimmed, {
29
+ header: true,
30
+ skipEmptyLines: true,
31
+ dynamicTyping: false,
32
+ });
33
+ // Check if parsing was successful and looks like valid CSV
34
+ const hasFields = resultWithHeaders.meta.fields && resultWithHeaders.meta.fields.length > 0;
35
+ const hasData = resultWithHeaders.data.length > 0;
36
+ const hasDelimiter = resultWithHeaders.meta.delimiter !== undefined;
37
+ if (!hasFields || !hasData || !hasDelimiter) {
38
+ return { isCsv: false, hasHeaders: false };
39
+ }
40
+ // Check if headers look valid (not numeric, reasonable length)
41
+ const fields = resultWithHeaders.meta.fields;
42
+ const looksLikeHeaders = fields.every(h => {
43
+ const cleaned = h.trim();
44
+ return (cleaned.length > 0 &&
45
+ cleaned.length < 100 &&
46
+ !/^[\d.]+$/.test(cleaned) // Not purely numeric
47
+ );
48
+ });
49
+ if (looksLikeHeaders) {
50
+ // Use header mode - convert to JSONL with objects
51
+ const jsonLines = resultWithHeaders.data.map(row => JSON.stringify(row));
52
+ return {
53
+ isCsv: true,
54
+ jsonlContent: jsonLines.join('\n'),
55
+ headers: fields,
56
+ rowCount: jsonLines.length,
57
+ hasHeaders: true,
58
+ delimiter: resultWithHeaders.meta.delimiter,
59
+ };
60
+ }
61
+ else {
62
+ // Headers don't look valid - parse without headers (as arrays)
63
+ const resultNoHeaders = Papa.parse(trimmed, {
64
+ header: false,
65
+ skipEmptyLines: true,
66
+ dynamicTyping: false,
67
+ });
68
+ const jsonLines = resultNoHeaders.data.map(row => JSON.stringify(row));
69
+ return {
70
+ isCsv: true,
71
+ jsonlContent: jsonLines.join('\n'),
72
+ headers: undefined,
73
+ rowCount: jsonLines.length,
74
+ hasHeaders: false,
75
+ delimiter: resultNoHeaders.meta.delimiter,
76
+ };
77
+ }
78
+ }
79
+ /**
80
+ * Detect whether content is JSON, JSONL, CSV, or plain text
81
+ * Returns format and CSV conversion result (if applicable) to avoid double parsing
14
82
  */
15
83
  function detectContentFormat(content) {
16
84
  const trimmed = content.trim();
17
85
  // Empty content
18
86
  if (!trimmed)
19
- return 'text';
87
+ return { format: 'text' };
20
88
  // Try parsing as single JSON object/array
21
89
  if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
22
90
  try {
23
91
  JSON.parse(trimmed);
24
- return 'json';
92
+ return { format: 'json' };
25
93
  }
26
94
  catch {
27
95
  // Could be JSONL starting with {
@@ -42,10 +110,15 @@ function detectContentFormat(content) {
42
110
  }
43
111
  });
44
112
  if (allSampledLinesAreJson) {
45
- return 'jsonl';
113
+ return { format: 'jsonl' };
46
114
  }
47
115
  }
48
- return 'text';
116
+ // Check for CSV format - store result to avoid re-parsing later
117
+ const csvResult = detectAndConvertCsv(trimmed);
118
+ if (csvResult.isCsv) {
119
+ return { format: 'csv', csvConversion: csvResult };
120
+ }
121
+ return { format: 'text' };
49
122
  }
50
123
  /**
51
124
  * Get file extension based on content format
@@ -353,24 +426,66 @@ export async function handleToolResponse(config, toolName, args, responseData) {
353
426
  // Success case: write to file
354
427
  try {
355
428
  // Detect content format for appropriate extension and schema
356
- const format = detectContentFormat(contentToWrite);
357
- const extension = getFileExtension(format);
429
+ // This also returns CSV conversion result if applicable, to avoid double parsing
430
+ const detection = detectContentFormat(contentToWrite);
431
+ // Handle format conversion to JSONL for non-JSON formats
432
+ let finalContent = contentToWrite;
433
+ let finalFormat = detection.format;
434
+ let conversionWarning;
435
+ if (detection.format === 'csv' && detection.csvConversion) {
436
+ // CSV: Use the already-converted JSONL from detection
437
+ const csvResult = detection.csvConversion;
438
+ if (csvResult.jsonlContent) {
439
+ finalContent = csvResult.jsonlContent;
440
+ finalFormat = 'jsonl';
441
+ const delimiterName = csvResult.delimiter === '\t' ? 'tab' :
442
+ csvResult.delimiter === ';' ? 'semicolon' :
443
+ csvResult.delimiter === '|' ? 'pipe' : 'comma';
444
+ if (csvResult.hasHeaders) {
445
+ conversionWarning = `NOTE: Original data was CSV/DSV format (${delimiterName}-delimited). ` +
446
+ `Converted to JSONL assuming first row was the header. ` +
447
+ `Headers detected: [${csvResult.headers?.join(', ')}]. ` +
448
+ `Rows converted: ${csvResult.rowCount}. ` +
449
+ `If the data looks incorrect, the first row may not have been a header.`;
450
+ }
451
+ else {
452
+ conversionWarning = `NOTE: Original data was CSV/DSV format (${delimiterName}-delimited) without detectable headers. ` +
453
+ `Converted to JSONL as arrays (one array per row). ` +
454
+ `Each row is: ["field1", "field2", ...]. ` +
455
+ `Rows converted: ${csvResult.rowCount}. ` +
456
+ `Access fields by index: .[0], .[1], etc.`;
457
+ }
458
+ }
459
+ }
460
+ else if (detection.format === 'text') {
461
+ // Plain text/markdown/other: Wrap each line as JSON object for jq compatibility
462
+ const lines = contentToWrite.split('\n');
463
+ const jsonLines = lines
464
+ .map((line, index) => JSON.stringify({ line_number: index + 1, content: line }));
465
+ finalContent = jsonLines.join('\n');
466
+ finalFormat = 'jsonl';
467
+ conversionWarning = `NOTE: Original data was plain text (not JSON/CSV). ` +
468
+ `Artificially converted to JSONL to make it accessible to the jq tool. ` +
469
+ `Each line is wrapped as: {"line_number": N, "content": "original line text"}. ` +
470
+ `Total lines: ${lines.length}.`;
471
+ }
472
+ const extension = getFileExtension(finalFormat);
358
473
  const filename = `${tool_id}${extension}`;
359
474
  const filepath = path.join(config.outputPath, filename);
360
475
  // Ensure output directory exists
361
476
  await fs.mkdir(config.outputPath, { recursive: true });
362
- // Write the exact content we counted
363
- await fs.writeFile(filepath, contentToWrite);
477
+ // Write the content (converted if CSV)
478
+ await fs.writeFile(filepath, finalContent);
364
479
  // Generate query-assist schema based on format
365
480
  let fileSchema;
366
- if (format === 'jsonl') {
481
+ if (finalFormat === 'jsonl') {
367
482
  // JSONL format: sample multiple records for schema
368
- const lines = contentToWrite.trim().split('\n').filter(l => l.trim());
483
+ const lines = finalContent.trim().split('\n').filter(l => l.trim());
369
484
  fileSchema = generateJsonlQueryAssistSchema(lines, {
370
485
  maxDepth: config.schemaMaxDepth ?? 2,
371
486
  maxPaths: config.schemaMaxPaths ?? 20,
372
487
  maxKeys: config.schemaMaxKeys ?? 50,
373
- dataSize: contentLength,
488
+ dataSize: finalContent.length,
374
489
  totalLines: lines.length,
375
490
  sampleSize: 5
376
491
  });
@@ -388,12 +503,16 @@ export async function handleToolResponse(config, toolName, args, responseData) {
388
503
  dataSize: contentLength
389
504
  });
390
505
  }
506
+ // Build message with optional conversion warning
507
+ const message = conversionWarning
508
+ ? `${conversionWarning}\n\n${FILE_WRITTEN_MESSAGE}`
509
+ : FILE_WRITTEN_MESSAGE;
391
510
  return {
392
511
  tool_id,
393
512
  wroteToFile: true,
394
513
  filePath: filepath,
395
514
  fileSchema,
396
- message: FILE_WRITTEN_MESSAGE,
515
+ message,
397
516
  };
398
517
  }
399
518
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anyshift/mcp-proxy",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
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",
@@ -15,6 +15,7 @@
15
15
  "dependencies": {
16
16
  "@modelcontextprotocol/sdk": "^1.24.0",
17
17
  "glob": "^13.0.0",
18
+ "papaparse": "^5.5.3",
18
19
  "zod": "^3.24.2"
19
20
  },
20
21
  "devDependencies": {
@@ -22,6 +23,7 @@
22
23
  "@types/glob": "^9.0.0",
23
24
  "@types/jest": "^30.0.0",
24
25
  "@types/node": "^22.0.0",
26
+ "@types/papaparse": "^5.5.2",
25
27
  "jest": "^30.2.0",
26
28
  "ts-jest": "^29.4.5",
27
29
  "tsx": "^4.7.0",