@anyshift/mcp-proxy 0.2.0
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 +314 -0
- package/dist/fileWriter/index.d.ts +18 -0
- package/dist/fileWriter/index.js +21 -0
- package/dist/fileWriter/schema.d.ts +15 -0
- package/dist/fileWriter/schema.js +120 -0
- package/dist/fileWriter/types.d.ts +4 -0
- package/dist/fileWriter/types.js +1 -0
- package/dist/fileWriter/writer.d.ts +10 -0
- package/dist/fileWriter/writer.js +277 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +346 -0
- package/dist/jq/handler.d.ts +14 -0
- package/dist/jq/handler.js +90 -0
- package/dist/jq/index.d.ts +51 -0
- package/dist/jq/index.js +26 -0
- package/dist/jq/tool.d.ts +43 -0
- package/dist/jq/tool.js +106 -0
- package/dist/jq/types.d.ts +4 -0
- package/dist/jq/types.js +1 -0
- package/dist/truncation/index.d.ts +8 -0
- package/dist/truncation/index.js +7 -0
- package/dist/truncation/truncate.d.ts +28 -0
- package/dist/truncation/truncate.js +80 -0
- package/dist/truncation/types.d.ts +26 -0
- package/dist/truncation/types.js +1 -0
- package/dist/types/index.d.ts +52 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/filename.d.ts +8 -0
- package/dist/utils/filename.js +42 -0
- package/dist/utils/pathValidation.d.ts +8 -0
- package/dist/utils/pathValidation.js +42 -0
- package/package.json +32 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { generateCompactFilename } from '../utils/filename.js';
|
|
4
|
+
import { analyzeJsonSchema, extractNullableFields } from './schema.js';
|
|
5
|
+
// Default minimum character count to trigger file writing
|
|
6
|
+
const DEFAULT_MIN_CHARS = 1000;
|
|
7
|
+
/**
|
|
8
|
+
* Helper function to detect if a response contains an error
|
|
9
|
+
*/
|
|
10
|
+
const isErrorResponse = (response) => {
|
|
11
|
+
if (!response || typeof response !== 'object')
|
|
12
|
+
return false;
|
|
13
|
+
const obj = response;
|
|
14
|
+
// Check MCP-level error flags first
|
|
15
|
+
if (obj.error || obj.isError) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
// For MCP responses, extract and parse the raw text to check for API-level errors
|
|
19
|
+
const rawText = extractRawText(obj);
|
|
20
|
+
if (rawText) {
|
|
21
|
+
try {
|
|
22
|
+
// Handle cases where text might contain "Listed incidents: {...}" or "Queried metrics data: {...}"
|
|
23
|
+
let jsonText = rawText;
|
|
24
|
+
// Extract JSON from common response patterns
|
|
25
|
+
const jsonMatch = rawText.match(/:\s*(\{.*\}|\[.*\])$/s);
|
|
26
|
+
if (jsonMatch) {
|
|
27
|
+
jsonText = jsonMatch[1];
|
|
28
|
+
}
|
|
29
|
+
// Try to parse as JSON to check for status: "error"
|
|
30
|
+
const parsed = JSON.parse(jsonText);
|
|
31
|
+
return parsed.status === 'error';
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// If JSON parsing fails, fall back to string matching
|
|
35
|
+
return (rawText.includes('"status":"error"') ||
|
|
36
|
+
rawText.includes('"status": "error"'));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Fallback: check if the response object directly has status === 'error'
|
|
40
|
+
return obj.status === 'error';
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Helper function to extract error message from API error responses
|
|
44
|
+
*/
|
|
45
|
+
const extractErrorMessage = (response) => {
|
|
46
|
+
if (!response || typeof response !== 'object') {
|
|
47
|
+
return 'Unknown error occurred';
|
|
48
|
+
}
|
|
49
|
+
const obj = response;
|
|
50
|
+
// Check for direct error properties first
|
|
51
|
+
if (obj.error && typeof obj.error === 'string') {
|
|
52
|
+
return obj.error;
|
|
53
|
+
}
|
|
54
|
+
// For MCP responses, extract error from parsed content
|
|
55
|
+
const rawText = extractRawText(obj);
|
|
56
|
+
if (rawText) {
|
|
57
|
+
try {
|
|
58
|
+
// Handle cases where text might contain "Listed incidents: {...}" or "Queried metrics data: {...}"
|
|
59
|
+
let jsonText = rawText;
|
|
60
|
+
// Extract JSON from common response patterns
|
|
61
|
+
const jsonMatch = rawText.match(/:\s*(\{.*\}|\[.*\])$/s);
|
|
62
|
+
if (jsonMatch) {
|
|
63
|
+
jsonText = jsonMatch[1];
|
|
64
|
+
}
|
|
65
|
+
const parsed = JSON.parse(jsonText);
|
|
66
|
+
// Try different error message fields
|
|
67
|
+
if (parsed.error && typeof parsed.error === 'string') {
|
|
68
|
+
return parsed.error;
|
|
69
|
+
}
|
|
70
|
+
if (parsed.message && typeof parsed.message === 'string') {
|
|
71
|
+
return parsed.message;
|
|
72
|
+
}
|
|
73
|
+
if (parsed.status === 'error') {
|
|
74
|
+
return 'API request failed with error status';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// If JSON parsing fails, look for error patterns in the raw text
|
|
79
|
+
const errorMatch = rawText.match(/"error":\s*"([^"]+)"/);
|
|
80
|
+
if (errorMatch && errorMatch[1]) {
|
|
81
|
+
return errorMatch[1];
|
|
82
|
+
}
|
|
83
|
+
const messageMatch = rawText.match(/"message":\s*"([^"]+)"/);
|
|
84
|
+
if (messageMatch && messageMatch[1]) {
|
|
85
|
+
return messageMatch[1];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return 'API error occurred';
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Extract raw text from MCP response format
|
|
93
|
+
*/
|
|
94
|
+
const extractRawText = (response) => {
|
|
95
|
+
if (response._rawText) {
|
|
96
|
+
return response._rawText;
|
|
97
|
+
}
|
|
98
|
+
if (response.content && Array.isArray(response.content)) {
|
|
99
|
+
const textContent = response.content
|
|
100
|
+
.filter((item) => item.type === 'text')
|
|
101
|
+
.map((item) => item.text)
|
|
102
|
+
.join('\n');
|
|
103
|
+
return textContent || null;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Centralized response handler with file writing capability
|
|
109
|
+
* @param config - File writer configuration
|
|
110
|
+
* @param toolName - Name of the tool that generated the response
|
|
111
|
+
* @param args - Arguments passed to the tool
|
|
112
|
+
* @param responseData - The response data to potentially write to file
|
|
113
|
+
* @returns Either the original response or a file reference response
|
|
114
|
+
*/
|
|
115
|
+
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') {
|
|
118
|
+
return responseData;
|
|
119
|
+
}
|
|
120
|
+
// If there's an error, return proper MCP error response (never write errors to file)
|
|
121
|
+
if (isErrorResponse(responseData)) {
|
|
122
|
+
const errorMessage = extractErrorMessage(responseData);
|
|
123
|
+
return {
|
|
124
|
+
content: [
|
|
125
|
+
{
|
|
126
|
+
type: 'text',
|
|
127
|
+
text: `Error: ${errorMessage}`,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
isError: true,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// If file writing is disabled, just return the response
|
|
134
|
+
if (!config.enabled || !config.outputPath) {
|
|
135
|
+
return responseData;
|
|
136
|
+
}
|
|
137
|
+
// 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
|
|
157
|
+
const minChars = config.minCharsForWrite ?? DEFAULT_MIN_CHARS;
|
|
158
|
+
if (contentLength < minChars) {
|
|
159
|
+
return responseData;
|
|
160
|
+
}
|
|
161
|
+
// Success case: write to file
|
|
162
|
+
try {
|
|
163
|
+
// Create compact, LLM-friendly filename
|
|
164
|
+
const filename = generateCompactFilename(toolName, args, config.toolAbbreviations);
|
|
165
|
+
const filepath = path.join(config.outputPath, filename);
|
|
166
|
+
// Ensure output directory exists
|
|
167
|
+
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
|
+
}
|
|
207
|
+
await fs.writeFile(filepath, contentToWrite);
|
|
208
|
+
// Try to generate schema if we have valid JSON
|
|
209
|
+
let schemaInfo = '';
|
|
210
|
+
let quickReference = '';
|
|
211
|
+
if (parsedForSchema) {
|
|
212
|
+
// Use the clean data (without pagination) for schema analysis
|
|
213
|
+
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)}`;
|
|
259
|
+
}
|
|
260
|
+
// Count lines in the content
|
|
261
|
+
const lineCount = contentToWrite.split('\n').length;
|
|
262
|
+
// Return success message with file path, size, lines, quick reference, and schema
|
|
263
|
+
return {
|
|
264
|
+
content: [
|
|
265
|
+
{
|
|
266
|
+
type: 'text',
|
|
267
|
+
text: `📄 File: ${filepath}\nSize: ${contentToWrite.length} characters | Lines: ${lineCount}${quickReference}${schemaInfo}`,
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
// If file writing fails, return the original response
|
|
274
|
+
console.error(`[handleToolResponse] Error writing file:`, error);
|
|
275
|
+
return responseData;
|
|
276
|
+
}
|
|
277
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generic MCP Proxy
|
|
4
|
+
*
|
|
5
|
+
* A universal wrapper for ANY Model Context Protocol (MCP) server that adds:
|
|
6
|
+
* 1. Response truncation (to stay within token limits)
|
|
7
|
+
* 2. Automatic file writing (for large responses)
|
|
8
|
+
* 3. JQ tool (for querying saved JSON files)
|
|
9
|
+
*
|
|
10
|
+
* This proxy is completely MCP-agnostic and works with any MCP server through
|
|
11
|
+
* a clean environment variable contract.
|
|
12
|
+
*
|
|
13
|
+
* MODES:
|
|
14
|
+
* - Wrapper Mode: When MCP_PROXY_CHILD_COMMAND is set, wraps another MCP server
|
|
15
|
+
* - Standalone Mode: When MCP_PROXY_CHILD_COMMAND is not set, provides only JQ tool
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generic MCP Proxy
|
|
4
|
+
*
|
|
5
|
+
* A universal wrapper for ANY Model Context Protocol (MCP) server that adds:
|
|
6
|
+
* 1. Response truncation (to stay within token limits)
|
|
7
|
+
* 2. Automatic file writing (for large responses)
|
|
8
|
+
* 3. JQ tool (for querying saved JSON files)
|
|
9
|
+
*
|
|
10
|
+
* This proxy is completely MCP-agnostic and works with any MCP server through
|
|
11
|
+
* a clean environment variable contract.
|
|
12
|
+
*
|
|
13
|
+
* MODES:
|
|
14
|
+
* - Wrapper Mode: When MCP_PROXY_CHILD_COMMAND is set, wraps another MCP server
|
|
15
|
+
* - Standalone Mode: When MCP_PROXY_CHILD_COMMAND is not set, provides only JQ tool
|
|
16
|
+
*/
|
|
17
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
18
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
19
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
20
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
21
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
22
|
+
import { createJqTool } from './jq/index.js';
|
|
23
|
+
import { truncateResponseIfNeeded } from './truncation/index.js';
|
|
24
|
+
import { createFileWriter } from './fileWriter/index.js';
|
|
25
|
+
/**
|
|
26
|
+
* ENVIRONMENT VARIABLE CONTRACT
|
|
27
|
+
* =============================
|
|
28
|
+
*
|
|
29
|
+
* The proxy uses a namespace convention to separate its configuration from
|
|
30
|
+
* the child MCP server's configuration:
|
|
31
|
+
*
|
|
32
|
+
* MCP_PROXY_* variables = Proxy's own configuration
|
|
33
|
+
* Everything else = Passed through to child MCP server
|
|
34
|
+
*
|
|
35
|
+
* This design allows the proxy to wrap ANY MCP server without knowing
|
|
36
|
+
* anything about its specific requirements.
|
|
37
|
+
*/
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// PROXY CONFIGURATION (MCP_PROXY_* prefix)
|
|
40
|
+
// ============================================================================
|
|
41
|
+
/**
|
|
42
|
+
* MCP_PROXY_CHILD_COMMAND (OPTIONAL)
|
|
43
|
+
* The command to spawn as the child MCP server
|
|
44
|
+
* If not provided, proxy runs in standalone mode (JQ tool only)
|
|
45
|
+
* Examples: "mcp-grafana", "npx @anyshift/anyshift-mcp-server", "node /path/to/server.js"
|
|
46
|
+
*/
|
|
47
|
+
const CHILD_COMMAND = process.env.MCP_PROXY_CHILD_COMMAND;
|
|
48
|
+
/**
|
|
49
|
+
* MCP_PROXY_CHILD_ARGS (OPTIONAL)
|
|
50
|
+
* Comma-separated list of arguments to pass to the child command
|
|
51
|
+
* Example: "arg1,arg2,arg3" becomes ["arg1", "arg2", "arg3"]
|
|
52
|
+
*/
|
|
53
|
+
const CHILD_ARGS = process.env.MCP_PROXY_CHILD_ARGS
|
|
54
|
+
? process.env.MCP_PROXY_CHILD_ARGS.split(',').map(s => s.trim()).filter(Boolean)
|
|
55
|
+
: [];
|
|
56
|
+
/**
|
|
57
|
+
* MCP_PROXY_MAX_TOKENS (OPTIONAL, default: 10000)
|
|
58
|
+
* Maximum tokens before truncating responses
|
|
59
|
+
* Calculated as: maxTokens * charsPerToken = max characters
|
|
60
|
+
* Default: 10000 tokens * 4 chars/token = 40,000 characters
|
|
61
|
+
*/
|
|
62
|
+
const MAX_TOKENS = parseInt(process.env.MCP_PROXY_MAX_TOKENS || '10000');
|
|
63
|
+
/**
|
|
64
|
+
* MCP_PROXY_CHARS_PER_TOKEN (OPTIONAL, default: 4)
|
|
65
|
+
* Average characters per token for truncation calculation
|
|
66
|
+
* Standard approximation: 1 token ≈ 4 characters
|
|
67
|
+
*/
|
|
68
|
+
const CHARS_PER_TOKEN = parseInt(process.env.MCP_PROXY_CHARS_PER_TOKEN || '4');
|
|
69
|
+
/**
|
|
70
|
+
* MCP_PROXY_WRITE_TO_FILE (OPTIONAL, default: false)
|
|
71
|
+
* Enable automatic file writing for large responses
|
|
72
|
+
* When enabled, responses above MIN_CHARS_FOR_WRITE are saved to disk
|
|
73
|
+
*/
|
|
74
|
+
const WRITE_TO_FILE = process.env.MCP_PROXY_WRITE_TO_FILE === 'true';
|
|
75
|
+
/**
|
|
76
|
+
* MCP_PROXY_OUTPUT_PATH (REQUIRED if WRITE_TO_FILE=true)
|
|
77
|
+
* Directory where response files should be saved
|
|
78
|
+
* Must be an absolute path
|
|
79
|
+
*/
|
|
80
|
+
const OUTPUT_PATH = process.env.MCP_PROXY_OUTPUT_PATH || '';
|
|
81
|
+
/**
|
|
82
|
+
* MCP_PROXY_MIN_CHARS_FOR_WRITE (OPTIONAL, default: 1000)
|
|
83
|
+
* Minimum response size (in characters) before saving to file
|
|
84
|
+
* Responses smaller than this are not written to disk
|
|
85
|
+
*/
|
|
86
|
+
const MIN_CHARS_FOR_WRITE = parseInt(process.env.MCP_PROXY_MIN_CHARS_FOR_WRITE || '1000');
|
|
87
|
+
/**
|
|
88
|
+
* MCP_PROXY_ENABLE_JQ (OPTIONAL, default: true)
|
|
89
|
+
* Enable the JQ tool for querying JSON files
|
|
90
|
+
* The JQ tool is added to the list of available tools from the child MCP
|
|
91
|
+
*/
|
|
92
|
+
const ENABLE_JQ = process.env.MCP_PROXY_ENABLE_JQ !== 'false'; // default true
|
|
93
|
+
/**
|
|
94
|
+
* MCP_PROXY_JQ_TIMEOUT_MS (OPTIONAL, default: 30000)
|
|
95
|
+
* Timeout in milliseconds for JQ query execution
|
|
96
|
+
*/
|
|
97
|
+
const JQ_TIMEOUT_MS = parseInt(process.env.MCP_PROXY_JQ_TIMEOUT_MS || '30000');
|
|
98
|
+
/**
|
|
99
|
+
* MCP_PROXY_ENABLE_LOGGING (OPTIONAL, default: false)
|
|
100
|
+
* Enable debug logging for the proxy
|
|
101
|
+
* Logs truncation events and file writing operations
|
|
102
|
+
*/
|
|
103
|
+
const ENABLE_LOGGING = process.env.MCP_PROXY_ENABLE_LOGGING === 'true';
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// VALIDATION
|
|
106
|
+
// ============================================================================
|
|
107
|
+
// Validate configuration
|
|
108
|
+
// CHILD_COMMAND is now optional - if not provided, proxy runs in standalone mode (JQ only)
|
|
109
|
+
if (!CHILD_COMMAND) {
|
|
110
|
+
console.error('[mcp-proxy] No child command specified - running in standalone mode (JQ only)');
|
|
111
|
+
if (!ENABLE_JQ) {
|
|
112
|
+
console.error('ERROR: Standalone mode requires JQ to be enabled (MCP_PROXY_ENABLE_JQ must not be false)');
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (WRITE_TO_FILE && !OUTPUT_PATH) {
|
|
117
|
+
console.error('ERROR: MCP_PROXY_OUTPUT_PATH is required when MCP_PROXY_WRITE_TO_FILE=true');
|
|
118
|
+
console.error('Example: export MCP_PROXY_OUTPUT_PATH="/tmp/mcp-results"');
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
// ============================================================================
|
|
122
|
+
// PASS-THROUGH ENVIRONMENT VARIABLES
|
|
123
|
+
// ============================================================================
|
|
124
|
+
/**
|
|
125
|
+
* Extract all environment variables that should be passed to the child MCP.
|
|
126
|
+
*
|
|
127
|
+
* We pass through everything EXCEPT:
|
|
128
|
+
* - Variables prefixed with MCP_PROXY_ (those are for the proxy itself)
|
|
129
|
+
* - System variables that shouldn't be inherited (PATH, HOME, etc. are handled by spawn)
|
|
130
|
+
*
|
|
131
|
+
* This allows the child MCP to receive exactly the environment variables it needs,
|
|
132
|
+
* such as:
|
|
133
|
+
* - GRAFANA_URL, GRAFANA_SERVICE_ACCOUNT_TOKEN
|
|
134
|
+
* - DATADOG_API_KEY, DATADOG_APP_KEY
|
|
135
|
+
* - API_TOKEN, API_BASE_URL
|
|
136
|
+
* - etc.
|
|
137
|
+
*/
|
|
138
|
+
const childEnv = {};
|
|
139
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
140
|
+
// Skip proxy configuration variables
|
|
141
|
+
if (key.startsWith('MCP_PROXY_')) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// Only include defined values
|
|
145
|
+
if (value !== undefined) {
|
|
146
|
+
childEnv[key] = value;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// ============================================================================
|
|
150
|
+
// CONFIGURATION SUMMARY
|
|
151
|
+
// ============================================================================
|
|
152
|
+
if (ENABLE_LOGGING) {
|
|
153
|
+
console.error('[mcp-proxy] Configuration:');
|
|
154
|
+
console.error(` Mode: ${CHILD_COMMAND ? 'wrapper' : 'standalone (JQ only)'}`);
|
|
155
|
+
if (CHILD_COMMAND) {
|
|
156
|
+
console.error(` Child command: ${CHILD_COMMAND}`);
|
|
157
|
+
console.error(` Child args: [${CHILD_ARGS.join(', ')}]`);
|
|
158
|
+
}
|
|
159
|
+
console.error(` Max tokens: ${MAX_TOKENS}`);
|
|
160
|
+
console.error(` Chars per token: ${CHARS_PER_TOKEN}`);
|
|
161
|
+
console.error(` Write to file: ${WRITE_TO_FILE}`);
|
|
162
|
+
if (WRITE_TO_FILE) {
|
|
163
|
+
console.error(` Output path: ${OUTPUT_PATH}`);
|
|
164
|
+
console.error(` Min chars for write: ${MIN_CHARS_FOR_WRITE}`);
|
|
165
|
+
}
|
|
166
|
+
console.error(` JQ tool enabled: ${ENABLE_JQ}`);
|
|
167
|
+
if (CHILD_COMMAND) {
|
|
168
|
+
console.error(` Pass-through env vars: ${Object.keys(childEnv).length}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// ============================================================================
|
|
172
|
+
// MAIN PROXY LOGIC
|
|
173
|
+
// ============================================================================
|
|
174
|
+
async function main() {
|
|
175
|
+
console.error('[mcp-proxy] Starting generic MCP proxy...');
|
|
176
|
+
// ------------------------------------------------------------------------
|
|
177
|
+
// 1. SPAWN CHILD MCP SERVER (if configured)
|
|
178
|
+
// ------------------------------------------------------------------------
|
|
179
|
+
let childClient = null;
|
|
180
|
+
let childToolsResponse = { tools: [] };
|
|
181
|
+
if (CHILD_COMMAND) {
|
|
182
|
+
console.error(`[mcp-proxy] Spawning child MCP: ${CHILD_COMMAND}`);
|
|
183
|
+
const childTransport = new StdioClientTransport({
|
|
184
|
+
command: CHILD_COMMAND,
|
|
185
|
+
args: CHILD_ARGS,
|
|
186
|
+
env: childEnv // All values are defined strings (filtered in extraction loop)
|
|
187
|
+
});
|
|
188
|
+
childClient = new Client({
|
|
189
|
+
name: 'mcp-proxy-client',
|
|
190
|
+
version: '1.0.0'
|
|
191
|
+
}, {
|
|
192
|
+
capabilities: {}
|
|
193
|
+
});
|
|
194
|
+
await childClient.connect(childTransport);
|
|
195
|
+
console.error('[mcp-proxy] Connected to child MCP');
|
|
196
|
+
// ------------------------------------------------------------------------
|
|
197
|
+
// 2. DISCOVER TOOLS FROM CHILD MCP
|
|
198
|
+
// ------------------------------------------------------------------------
|
|
199
|
+
childToolsResponse = await childClient.listTools();
|
|
200
|
+
console.error(`[mcp-proxy] Discovered ${childToolsResponse.tools.length} tools from child MCP`);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
console.error('[mcp-proxy] Standalone mode - no child MCP');
|
|
204
|
+
}
|
|
205
|
+
// ------------------------------------------------------------------------
|
|
206
|
+
// 3. CREATE PROXY SERVER
|
|
207
|
+
// ------------------------------------------------------------------------
|
|
208
|
+
const server = new Server({
|
|
209
|
+
name: 'mcp-proxy',
|
|
210
|
+
version: '1.0.0'
|
|
211
|
+
}, {
|
|
212
|
+
capabilities: {
|
|
213
|
+
tools: {}
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
// ------------------------------------------------------------------------
|
|
217
|
+
// 4. INITIALIZE PROXY CAPABILITIES
|
|
218
|
+
// ------------------------------------------------------------------------
|
|
219
|
+
// Truncation configuration
|
|
220
|
+
const truncationConfig = {
|
|
221
|
+
maxTokens: MAX_TOKENS,
|
|
222
|
+
charsPerToken: CHARS_PER_TOKEN,
|
|
223
|
+
enableLogging: ENABLE_LOGGING,
|
|
224
|
+
messagePrefix: 'RESPONSE TRUNCATED' // Can be customized per service
|
|
225
|
+
};
|
|
226
|
+
// File writer configuration
|
|
227
|
+
const fileWriterConfig = {
|
|
228
|
+
enabled: WRITE_TO_FILE,
|
|
229
|
+
outputPath: OUTPUT_PATH,
|
|
230
|
+
minCharsForWrite: MIN_CHARS_FOR_WRITE,
|
|
231
|
+
toolAbbreviations: {} // No service-specific abbreviations (generic proxy)
|
|
232
|
+
};
|
|
233
|
+
const fileWriter = createFileWriter(fileWriterConfig);
|
|
234
|
+
// JQ tool configuration
|
|
235
|
+
let jqTool = null;
|
|
236
|
+
if (ENABLE_JQ) {
|
|
237
|
+
jqTool = createJqTool({
|
|
238
|
+
allowedPaths: [process.cwd(), OUTPUT_PATH].filter(Boolean),
|
|
239
|
+
timeoutMs: JQ_TIMEOUT_MS
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// ------------------------------------------------------------------------
|
|
243
|
+
// 5. REGISTER ALL TOOLS (CHILD + PROXY)
|
|
244
|
+
// ------------------------------------------------------------------------
|
|
245
|
+
const allTools = [
|
|
246
|
+
...childToolsResponse.tools,
|
|
247
|
+
...(jqTool ? [jqTool.toolDefinition] : [])
|
|
248
|
+
];
|
|
249
|
+
console.error(`[mcp-proxy] Exposing ${allTools.length} tools total (${childToolsResponse.tools.length} from child${jqTool ? ' + 1 JQ' : ''})`);
|
|
250
|
+
// ------------------------------------------------------------------------
|
|
251
|
+
// 6. HANDLE TOOL LIST REQUESTS
|
|
252
|
+
// ------------------------------------------------------------------------
|
|
253
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
254
|
+
return { tools: allTools };
|
|
255
|
+
});
|
|
256
|
+
// ------------------------------------------------------------------------
|
|
257
|
+
// 7. HANDLE TOOL CALL REQUESTS (WITH TRUNCATION & FILE WRITING)
|
|
258
|
+
// ------------------------------------------------------------------------
|
|
259
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
260
|
+
const toolName = request.params.name;
|
|
261
|
+
const toolArgs = request.params.arguments || {};
|
|
262
|
+
if (ENABLE_LOGGING) {
|
|
263
|
+
console.error(`[mcp-proxy] Tool call: ${toolName}`);
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
// Handle JQ tool locally (if enabled)
|
|
267
|
+
if (toolName === 'execute_jq_query' && jqTool) {
|
|
268
|
+
if (ENABLE_LOGGING) {
|
|
269
|
+
console.error('[mcp-proxy] Executing JQ tool locally');
|
|
270
|
+
}
|
|
271
|
+
return await jqTool.handler({
|
|
272
|
+
params: { arguments: toolArgs }
|
|
273
|
+
});
|
|
274
|
+
}
|
|
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}`);
|
|
287
|
+
}
|
|
288
|
+
const result = await childClient.callTool({
|
|
289
|
+
name: toolName,
|
|
290
|
+
arguments: toolArgs
|
|
291
|
+
});
|
|
292
|
+
// Apply truncation and file writing to text responses
|
|
293
|
+
if (result.content && Array.isArray(result.content) && result.content.length > 0) {
|
|
294
|
+
for (let i = 0; i < result.content.length; i++) {
|
|
295
|
+
const item = result.content[i];
|
|
296
|
+
if (item.type === 'text' && typeof item.text === 'string') {
|
|
297
|
+
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)
|
|
304
|
+
if (fileWriterConfig.enabled) {
|
|
305
|
+
const fileResult = await fileWriter.handleResponse(toolName, toolArgs, {
|
|
306
|
+
content: [{ type: 'text', text: item.text }]
|
|
307
|
+
});
|
|
308
|
+
if (fileResult && fileResult.content && Array.isArray(fileResult.content) &&
|
|
309
|
+
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}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
console.error(`[mcp-proxy] Error executing tool ${toolName}:`, error);
|
|
323
|
+
return {
|
|
324
|
+
content: [{
|
|
325
|
+
type: 'text',
|
|
326
|
+
text: `Error executing ${toolName}: ${error.message || String(error)}`
|
|
327
|
+
}],
|
|
328
|
+
isError: true
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
// ------------------------------------------------------------------------
|
|
333
|
+
// 8. CONNECT PROXY TO STDIO
|
|
334
|
+
// ------------------------------------------------------------------------
|
|
335
|
+
const transport = new StdioServerTransport();
|
|
336
|
+
await server.connect(transport);
|
|
337
|
+
console.error('[mcp-proxy] Proxy server ready on stdio');
|
|
338
|
+
console.error('[mcp-proxy] Waiting for MCP protocol messages...');
|
|
339
|
+
}
|
|
340
|
+
// ============================================================================
|
|
341
|
+
// START THE PROXY
|
|
342
|
+
// ============================================================================
|
|
343
|
+
main().catch((error) => {
|
|
344
|
+
console.error('[mcp-proxy] Fatal error:', error);
|
|
345
|
+
process.exit(1);
|
|
346
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { JqConfig } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Execute a JQ query on a JSON file
|
|
4
|
+
* @param config - JQ configuration
|
|
5
|
+
* @param jqQuery - The JQ query to execute
|
|
6
|
+
* @param filePath - Absolute path to the JSON file
|
|
7
|
+
* @returns Promise with the query result
|
|
8
|
+
*/
|
|
9
|
+
export declare function executeJqQuery(config: JqConfig, jqQuery: string, filePath: string): Promise<{
|
|
10
|
+
content: Array<{
|
|
11
|
+
type: 'text';
|
|
12
|
+
text: string;
|
|
13
|
+
}>;
|
|
14
|
+
}>;
|