@anyshift/mcp-proxy 0.2.1 → 0.2.3-dev

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 @@
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
+ });
@@ -15,4 +15,4 @@ export declare function createFileWriter(config: FileWriterConfig): {
15
15
  handleResponse: (toolName: string, args: Record<string, unknown>, responseData: unknown) => Promise<FileWriterResult | unknown>;
16
16
  };
17
17
  export type { FileWriterConfig, FileWriterResult } from './types.js';
18
- export { analyzeJsonSchema, extractNullableFields } from './schema.js';
18
+ export { generateQueryAssistSchema } from './schema.js';
@@ -18,4 +18,4 @@ export function createFileWriter(config) {
18
18
  },
19
19
  };
20
20
  }
21
- export { analyzeJsonSchema, extractNullableFields } from './schema.js';
21
+ export { generateQueryAssistSchema } from './schema.js';
@@ -1,15 +1,42 @@
1
- import { JsonSchema, NullableFields } from '../types/index.js';
2
1
  /**
3
- * Analyze JSON structure and generate enhanced schema
4
- * @param obj - The object to analyze
5
- * @param path - Current path in the object (for debugging)
6
- * @returns Schema representation of the object
2
+ * Query-Assist Schema Generator
3
+ *
4
+ * Generates compact, LLM-friendly schemas optimized for crafting JQ queries.
5
+ * Uses JQ-style path notation (.items[].price) instead of JSON Schema.
6
+ * Includes exploration prompts when limits are reached.
7
7
  */
8
- export declare function analyzeJsonSchema(obj: unknown, path?: string): JsonSchema;
8
+ export interface PathInfo {
9
+ path: string;
10
+ type: string;
11
+ depth: number;
12
+ nullable?: boolean;
13
+ arrayLength?: number;
14
+ keyCount?: number;
15
+ mixed?: boolean;
16
+ }
17
+ export interface LimitMetadata {
18
+ depthLimitHit: boolean;
19
+ keyLimitHit: boolean;
20
+ pathLimitHit: boolean;
21
+ mixedSchemasDetected: boolean;
22
+ maxDepth: number;
23
+ maxKeys: number;
24
+ maxPaths: number;
25
+ totalPathsFound: number;
26
+ deepestPathTruncated?: string;
27
+ truncatedKeyCount?: number;
28
+ }
29
+ export interface QueryAssistOptions {
30
+ maxDepth?: number;
31
+ maxPaths?: number;
32
+ maxKeys?: number;
33
+ dataSize?: number;
34
+ }
9
35
  /**
10
- * Extract nullable and always-null fields from schema
11
- * @param schema - The schema to analyze
12
- * @param basePath - Base path for field names
13
- * @returns Object containing arrays of always-null and nullable field paths
36
+ * Generate query-assist schema for JSON data
37
+ * Main entry point for schema generation
38
+ * @param data - JSON data to analyze
39
+ * @param options - Configuration options
40
+ * @returns Compact text schema optimized for JQ queries
14
41
  */
15
- export declare function extractNullableFields(schema: unknown, basePath?: string): NullableFields;
42
+ export declare function generateQueryAssistSchema(data: unknown, options?: QueryAssistOptions): string;
@@ -1,120 +1,270 @@
1
1
  /**
2
- * Analyze JSON structure and generate enhanced schema
3
- * @param obj - The object to analyze
4
- * @param path - Current path in the object (for debugging)
5
- * @returns Schema representation of the object
2
+ * Query-Assist Schema Generator
3
+ *
4
+ * Generates compact, LLM-friendly schemas optimized for crafting JQ queries.
5
+ * Uses JQ-style path notation (.items[].price) instead of JSON Schema.
6
+ * Includes exploration prompts when limits are reached.
6
7
  */
7
- export function analyzeJsonSchema(obj, path = 'root') {
8
- if (obj === null)
9
- return { type: 'null' };
10
- if (obj === undefined)
11
- return { type: 'undefined' };
12
- const type = Array.isArray(obj) ? 'array' : typeof obj;
13
- if (type === 'object') {
14
- const properties = {};
15
- const objRecord = obj;
16
- const keys = Object.keys(objRecord);
17
- // Detect numeric string keys (common in Cypher results)
18
- const numericKeys = keys.filter((k) => /^\d+$/.test(k));
19
- const hasNumericKeys = keys.length > 0 && numericKeys.length >= keys.length * 0.8;
20
- for (const key in objRecord) {
21
- if (Object.prototype.hasOwnProperty.call(objRecord, key)) {
22
- properties[key] = analyzeJsonSchema(objRecord[key], `${path}.${key}`);
8
+ /**
9
+ * Collect all paths from JSON data with limits applied
10
+ * @param data - The JSON data to analyze
11
+ * @param maxDepth - Maximum depth to traverse (default: 2)
12
+ * @param maxKeys - Maximum keys to analyze per object (default: 50)
13
+ * @returns Array of path information and limit metadata
14
+ */
15
+ function collectPaths(data, maxDepth = 2, maxKeys = 50) {
16
+ const paths = [];
17
+ const limits = {
18
+ depthLimitHit: false,
19
+ keyLimitHit: false,
20
+ pathLimitHit: false,
21
+ mixedSchemasDetected: false,
22
+ maxDepth,
23
+ maxKeys,
24
+ maxPaths: 0, // Will be set later
25
+ totalPathsFound: 0
26
+ };
27
+ function traverse(val, path, depth) {
28
+ // Hard stop at max depth
29
+ if (depth > maxDepth) {
30
+ limits.depthLimitHit = true;
31
+ if (!limits.deepestPathTruncated) {
32
+ limits.deepestPathTruncated = path;
23
33
  }
34
+ return;
24
35
  }
25
- const schema = { type: 'object', properties };
26
- // Add metadata hints for numeric keys
27
- if (hasNumericKeys) {
28
- schema._keysAreNumeric = true;
29
- schema._accessPattern = 'Use .["0"] not .[0]';
36
+ if (val === null) {
37
+ paths.push({ path, type: 'null', depth, nullable: true });
30
38
  }
31
- return schema;
32
- }
33
- else if (type === 'array') {
34
- const arr = obj;
35
- if (arr.length === 0) {
36
- return { type: 'array', items: { type: 'unknown' }, length: 0 };
39
+ else if (Array.isArray(val)) {
40
+ paths.push({ path, type: 'array', depth, arrayLength: val.length });
41
+ if (val.length === 0) {
42
+ return; // Empty array, nothing to explore
43
+ }
44
+ // Sample first 5 items to detect schema variance
45
+ const sample = val.slice(0, Math.min(5, val.length));
46
+ const types = new Set(sample.map(item => item === null
47
+ ? 'null'
48
+ : Array.isArray(item)
49
+ ? 'array'
50
+ : typeof item));
51
+ // Detect mixed schemas (heterogeneous arrays)
52
+ // Mixed if we have more than 1 distinct type
53
+ const mixed = types.size > 1;
54
+ if (mixed) {
55
+ limits.mixedSchemasDetected = true;
56
+ }
57
+ // Traverse first non-null item
58
+ const first = sample.find(v => v !== null);
59
+ if (first !== undefined) {
60
+ const arrayPath = `${path}[]`;
61
+ if (mixed) {
62
+ // For mixed arrays, only show the mixed marker (don't traverse to avoid duplicate paths)
63
+ paths.push({ path: arrayPath, type: 'mixed', depth: depth + 1, mixed: true });
64
+ }
65
+ else {
66
+ // For uniform arrays, traverse to show the structure
67
+ traverse(first, arrayPath, depth + 1);
68
+ }
69
+ }
70
+ // For objects in arrays, also check if they have different keys
71
+ if (types.has('object')) {
72
+ const objects = sample.filter(v => v && typeof v === 'object' && !Array.isArray(v));
73
+ if (objects.length >= 2) {
74
+ const keySets = objects.map(o => new Set(Object.keys(o)));
75
+ // Check if any two objects have different keys
76
+ for (let i = 0; i < keySets.length - 1; i++) {
77
+ const keys1 = Array.from(keySets[i]);
78
+ const keys2 = Array.from(keySets[i + 1]);
79
+ if (keys1.length !== keys2.length || !keys1.every(k => keySets[i + 1].has(k))) {
80
+ limits.mixedSchemasDetected = true;
81
+ break;
82
+ }
83
+ }
84
+ }
85
+ }
37
86
  }
38
- // Analyze array items for mixed types and nulls
39
- const itemTypes = new Set();
40
- let hasNulls = false;
41
- // Sample first 10 items to detect type variance
42
- const sampled = arr.slice(0, Math.min(10, arr.length));
43
- for (const item of sampled) {
44
- if (item === null) {
45
- hasNulls = true;
46
- itemTypes.add('null');
87
+ else if (typeof val === 'object') {
88
+ const keys = Object.keys(val).sort();
89
+ // Check for numeric keys (common pattern - treat as collection like arrays)
90
+ const numericKeys = keys.filter(k => /^\d+$/.test(k));
91
+ const hasNumericKeys = keys.length > 0 && numericKeys.length >= keys.length * 0.8;
92
+ // Always show key count (including 0)
93
+ paths.push({
94
+ path,
95
+ type: hasNumericKeys ? 'object (numeric keys)' : 'object',
96
+ depth,
97
+ keyCount: keys.length
98
+ });
99
+ if (hasNumericKeys) {
100
+ // Treat as collection - show ONE representative item structure
101
+ // This avoids enumerating .0, .1, .2, ... which is repetitive and wastes space
102
+ const representativePath = path ? `${path}.[<idx>]` : `.[<idx>]`;
103
+ // Pick first key to show structure
104
+ if (keys.length > 0) {
105
+ const firstKey = keys[0];
106
+ traverse(val[firstKey], representativePath, depth + 1);
107
+ }
47
108
  }
48
109
  else {
49
- itemTypes.add(Array.isArray(item) ? 'array' : typeof item);
110
+ // Normal object - traverse keys individually
111
+ const keysToAnalyze = keys.slice(0, maxKeys);
112
+ if (keys.length > maxKeys) {
113
+ limits.keyLimitHit = true;
114
+ limits.truncatedKeyCount = keys.length - maxKeys;
115
+ }
116
+ // Traverse child keys
117
+ for (const key of keysToAnalyze) {
118
+ const childPath = path ? `${path}.${key}` : `.${key}`;
119
+ traverse(val[key], childPath, depth + 1);
120
+ }
50
121
  }
51
122
  }
52
- const schema = {
53
- type: 'array',
54
- items: itemTypes.size === 1 && !hasNulls
55
- ? analyzeJsonSchema(arr[0], `${path}[0]`)
56
- : { types: Array.from(itemTypes) },
57
- length: arr.length,
58
- };
59
- // Add hints for null handling
60
- if (hasNulls) {
61
- schema._hasNulls = true;
123
+ else {
124
+ // Primitive types
125
+ paths.push({ path, type: typeof val, depth });
62
126
  }
63
- return schema;
64
- }
65
- else {
66
- return { type };
67
127
  }
128
+ traverse(data, '', 0);
129
+ limits.totalPathsFound = paths.length;
130
+ return { paths, limits };
68
131
  }
69
132
  /**
70
- * Extract nullable and always-null fields from schema
71
- * @param schema - The schema to analyze
72
- * @param basePath - Base path for field names
73
- * @returns Object containing arrays of always-null and nullable field paths
133
+ * Select top N most relevant paths using scoring
134
+ * @param paths - All collected paths
135
+ * @param maxPaths - Maximum paths to return
136
+ * @returns Prioritized subset of paths
74
137
  */
75
- export function extractNullableFields(schema, basePath = '') {
76
- const alwaysNull = [];
77
- const nullable = [];
78
- function traverse(s, path) {
79
- if (!s || typeof s !== 'object')
80
- return;
81
- const schemaObj = s;
82
- // Check if this field is always null
83
- if (schemaObj.type === 'null') {
84
- alwaysNull.push(path);
85
- return;
86
- }
87
- // Check if this field can be null (mixed types)
88
- if (schemaObj.items && typeof schemaObj.items === 'object') {
89
- const items = schemaObj.items;
90
- if (items.types &&
91
- Array.isArray(items.types) &&
92
- items.types.includes('null')) {
93
- nullable.push(path);
138
+ function selectTopPaths(paths, maxPaths) {
139
+ // Score each path based on relevance
140
+ const scored = paths.map(p => ({
141
+ ...p,
142
+ score: (p.nullable ? 0 : 10) + // Non-null = higher priority
143
+ (3 - p.depth) * 5 + // Shallower = higher priority
144
+ (p.type === 'array' ? 5 : 0) + // Arrays = interesting
145
+ (p.type === 'object' || p.type === 'object (numeric keys)' ? 3 : 0) + // Objects = interesting
146
+ (p.mixed ? 2 : 0) // Mixed types = interesting
147
+ }));
148
+ // Sort by score (desc), then by path length (asc) for stability
149
+ return scored
150
+ .sort((a, b) => {
151
+ if (b.score !== a.score)
152
+ return b.score - a.score;
153
+ return a.path.length - b.path.length;
154
+ })
155
+ .slice(0, maxPaths);
156
+ }
157
+ /**
158
+ * Format paths as query-assist text with exploration prompts
159
+ * @param paths - Selected paths to display
160
+ * @param limits - Limit metadata for generating prompts
161
+ * @param dataSize - Size of original data in characters
162
+ * @returns Formatted text schema
163
+ */
164
+ function formatQueryAssist(paths, limits, dataSize) {
165
+ let output = '📊 STRUCTURE GUIDE (for JQ queries)\n\n';
166
+ output += `Size: ${dataSize.toLocaleString()} characters\n\n`;
167
+ // Group paths by depth
168
+ const byDepth = {};
169
+ for (const p of paths) {
170
+ if (!byDepth[p.depth])
171
+ byDepth[p.depth] = [];
172
+ byDepth[p.depth].push(p);
173
+ }
174
+ // Format paths by depth levels
175
+ const depths = Object.keys(byDepth)
176
+ .map(Number)
177
+ .sort((a, b) => a - b);
178
+ for (const depth of depths) {
179
+ const depthPaths = byDepth[depth];
180
+ const label = depth === 0 ? 'ROOT' : depth === 1 ? 'TOP-LEVEL' : `NESTED (depth ${depth})`;
181
+ output += `${label}:\n`;
182
+ for (const p of depthPaths) {
183
+ const pathStr = p.path || '(root)';
184
+ output += ` ${pathStr.padEnd(35)}`;
185
+ output += ` → ${p.type}`;
186
+ if (p.arrayLength !== undefined) {
187
+ output += `[${p.arrayLength}]`;
94
188
  }
95
- }
96
- // Recurse into object properties
97
- if (schemaObj.type === 'object' && schemaObj.properties) {
98
- const props = schemaObj.properties;
99
- for (const [key, value] of Object.entries(props)) {
100
- const newPath = path ? `${path}.${key}` : key;
101
- traverse(value, newPath);
189
+ if (p.keyCount !== undefined) {
190
+ output += ` (${p.keyCount} keys)`;
102
191
  }
192
+ if (p.nullable) {
193
+ output += ' (nullable)';
194
+ }
195
+ if (p.mixed) {
196
+ output += ' ⚠️ MIXED SCHEMAS';
197
+ }
198
+ if (depth === limits.maxDepth && (p.type === 'object' || p.type === 'object (numeric keys)' || p.type === 'array')) {
199
+ output += ' ⚠️ DEPTH LIMIT';
200
+ }
201
+ output += '\n';
103
202
  }
104
- // Recurse into array items
105
- if (schemaObj.type === 'array' &&
106
- schemaObj.items &&
107
- typeof schemaObj.items === 'object') {
108
- const items = schemaObj.items;
109
- if (items.type === 'object' && items.properties) {
110
- const props = items.properties;
111
- for (const [key, value] of Object.entries(props)) {
112
- const newPath = path ? `${path}[].${key}` : `[].${key}`;
113
- traverse(value, newPath);
114
- }
203
+ output += '\n';
204
+ }
205
+ // Check if we have numeric-keyed objects in the output
206
+ const hasNumericKeys = paths.some(p => p.path.includes('.[<idx>]'));
207
+ // Build exploration guide if any limits were hit or special patterns detected
208
+ const hasLimits = limits.depthLimitHit || limits.keyLimitHit || limits.pathLimitHit || limits.mixedSchemasDetected;
209
+ if (hasNumericKeys || hasLimits) {
210
+ output += '💡 EXPLORATION GUIDE\n\n';
211
+ // Numeric keys note (data-specific, keep separate)
212
+ if (hasNumericKeys) {
213
+ output += 'NUMERIC KEYS: .[<idx>] represents structure shared by all numeric keys\n';
214
+ output += ' Access: .["0"], .["1"] or .[0], .[1] (array-style) | List: keys\n\n';
215
+ }
216
+ // Show which limits were hit
217
+ if (hasLimits) {
218
+ const limitWarnings = [];
219
+ if (limits.depthLimitHit) {
220
+ limitWarnings.push(`DEPTH (max: ${limits.maxDepth})`);
221
+ }
222
+ if (limits.keyLimitHit && limits.truncatedKeyCount) {
223
+ limitWarnings.push(`KEYS (${limits.maxKeys} shown, ${limits.truncatedKeyCount} more)`);
224
+ }
225
+ if (limits.pathLimitHit) {
226
+ limitWarnings.push(`PATHS (${limits.maxPaths} of ${limits.totalPathsFound})`);
227
+ }
228
+ if (limits.mixedSchemasDetected) {
229
+ limitWarnings.push('MIXED SCHEMAS');
115
230
  }
231
+ output += `⚠️ Limits: ${limitWarnings.join(' | ')}\n\n`;
232
+ // Generic JQ exploration patterns
233
+ output += 'Common JQ patterns:\n';
234
+ output += ' • View keys: <path> | keys\n';
235
+ output += ' • Check type: <path> | type\n';
236
+ output += ' • Count items: <path> | length\n';
237
+ output += ' • Search keys: keys | map(select(contains("term")))\n';
238
+ output += ' • List all paths: paths(scalars) | map(join("."))\n';
239
+ output += ' • Filter arrays: .[] | select(type == "object")\n';
240
+ if (limits.mixedSchemasDetected) {
241
+ output += ' • Check variance: .[] | type or [:3] | map(keys)\n';
242
+ }
243
+ output += '\n';
116
244
  }
117
245
  }
118
- traverse(schema, basePath);
119
- return { alwaysNull, nullable };
246
+ return output;
247
+ }
248
+ /**
249
+ * Generate query-assist schema for JSON data
250
+ * Main entry point for schema generation
251
+ * @param data - JSON data to analyze
252
+ * @param options - Configuration options
253
+ * @returns Compact text schema optimized for JQ queries
254
+ */
255
+ export function generateQueryAssistSchema(data, options = {}) {
256
+ const maxDepth = options.maxDepth ?? 2;
257
+ const maxPaths = options.maxPaths ?? 20;
258
+ const maxKeys = options.maxKeys ?? 50;
259
+ // Collect paths with limits
260
+ const { paths, limits } = collectPaths(data, maxDepth, maxKeys);
261
+ // Select top paths
262
+ const selectedPaths = selectTopPaths(paths, maxPaths);
263
+ // Update limit metadata
264
+ limits.maxPaths = maxPaths;
265
+ limits.pathLimitHit = paths.length > maxPaths;
266
+ // Calculate data size (use provided size if available to avoid re-stringifying)
267
+ const dataSize = options.dataSize ?? JSON.stringify(data).length;
268
+ // Format as text
269
+ return formatQueryAssist(selectedPaths, limits, dataSize);
120
270
  }
@@ -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
  /**
@@ -160,8 +160,8 @@ const extractContentForFile = (responseData) => {
160
160
  * @returns Either the original response or a file reference response
161
161
  */
162
162
  export async function handleToolResponse(config, toolName, args, responseData) {
163
- // JQ query tool should always return directly to AI (never write to file)
164
- 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') {
165
165
  return responseData;
166
166
  }
167
167
  // If there's an error, return proper MCP error response (never write errors to file)
@@ -199,66 +199,28 @@ export async function handleToolResponse(config, toolName, args, responseData) {
199
199
  await fs.mkdir(config.outputPath, { recursive: true });
200
200
  // Write the exact content we counted
201
201
  await fs.writeFile(filepath, contentToWrite);
202
- // Try to generate schema if we have valid JSON
202
+ // Generate query-assist schema if we have valid JSON
203
203
  let schemaInfo = '';
204
- let quickReference = '';
205
204
  if (parsedForSchema) {
206
205
  // Use the clean data (without pagination) for schema analysis
207
206
  const { pagination, has_more, next_page, previous_page, page, page_size, total_pages, ...cleanData } = parsedForSchema;
208
- const schema = analyzeJsonSchema(cleanData);
209
- const nullFields = extractNullableFields(schema);
210
- const schemaObj = schema;
211
- // Build quick reference section
212
- quickReference += `\n\n🔍 UNDERSTAND THIS SCHEMA BEFORE WRITING JQ QUERIES:\n`;
213
- // Structure hints
214
- if (schemaObj._keysAreNumeric) {
215
- quickReference += ` • Structure: Object with numeric keys ("0", "1", ...) - use .["0"]\n`;
216
- }
217
- else if (schemaObj.type === 'array') {
218
- quickReference += ` • Structure: Array with ${schemaObj.length} items\n`;
219
- }
220
- else if (schemaObj.type === 'object' && schemaObj.properties) {
221
- const props = schemaObj.properties;
222
- const keys = Object.keys(props).slice(0, 5).join(', ');
223
- quickReference += ` • Structure: Object with keys: ${keys}\n`;
224
- }
225
- // Always null fields
226
- if (nullFields.alwaysNull.length > 0) {
227
- const fieldList = nullFields.alwaysNull.slice(0, 5).join(', ');
228
- const more = nullFields.alwaysNull.length > 5
229
- ? ` (+${nullFields.alwaysNull.length - 5} more)`
230
- : '';
231
- quickReference += ` • Always null: ${fieldList}${more}\n`;
232
- }
233
- // Nullable fields
234
- if (nullFields.nullable.length > 0) {
235
- const fieldList = nullFields.nullable.slice(0, 5).join(', ');
236
- const more = nullFields.nullable.length > 5
237
- ? ` (+${nullFields.nullable.length - 5} more)`
238
- : '';
239
- quickReference += ` • Sometimes null: ${fieldList}${more}\n`;
240
- }
241
- // Suggest exploratory queries
242
- if (schemaObj._keysAreNumeric) {
243
- quickReference += ` • Explore: keys, .["0"] | keys, .["0"]\n`;
244
- }
245
- else if (schemaObj.type === 'array' && schemaObj.length > 0) {
246
- quickReference += ` • Explore: length, .[0] | keys, .[0]\n`;
247
- }
248
- else {
249
- quickReference += ` • Explore: keys, type\n`;
250
- }
251
- // Full schema
252
- 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
+ })}`;
253
215
  }
254
216
  // Count lines in the content
255
217
  const lineCount = contentToWrite.split('\n').length;
256
- // Return success message with file path, size, lines, quick reference, and schema
218
+ // Return success message with file path, size, lines, and schema
257
219
  return {
258
220
  content: [
259
221
  {
260
222
  type: 'text',
261
- text: `📄 File: ${filepath}\nSize: ${contentToWrite.length} characters | Lines: ${lineCount}${quickReference}${schemaInfo}`,
223
+ text: `📄 File: ${filepath}\nSize: ${contentToWrite.length} characters | Lines: ${lineCount}${schemaInfo}`,
262
224
  },
263
225
  ],
264
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
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.1",
3
+ "version": "0.2.3-dev",
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",