@probelabs/probe 0.6.0-rc102 → 0.6.0-rc104

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@probelabs/probe",
3
- "version": "0.6.0-rc102",
3
+ "version": "0.6.0-rc104",
4
4
  "description": "Node.js wrapper for the probe code search tool",
5
5
  "main": "src/index.js",
6
6
  "module": "src/index.js",
@@ -68,6 +68,7 @@
68
68
  "author": "probelabs",
69
69
  "license": "ISC",
70
70
  "dependencies": {
71
+ "@ai-sdk/amazon-bedrock": "^1.0.8",
71
72
  "@ai-sdk/anthropic": "^2.0.8",
72
73
  "@ai-sdk/google": "^2.0.14",
73
74
  "@ai-sdk/openai": "^2.0.10",
@@ -2,9 +2,13 @@
2
2
  import { createAnthropic } from '@ai-sdk/anthropic';
3
3
  import { createOpenAI } from '@ai-sdk/openai';
4
4
  import { createGoogleGenerativeAI } from '@ai-sdk/google';
5
+ import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
5
6
  import { streamText } from 'ai';
6
7
  import { randomUUID } from 'crypto';
7
8
  import { EventEmitter } from 'events';
9
+ import { existsSync } from 'fs';
10
+ import { readFile, stat } from 'fs/promises';
11
+ import { resolve, isAbsolute } from 'path';
8
12
  import { TokenCounter } from './tokenCounter.js';
9
13
  import {
10
14
  createTools,
@@ -46,6 +50,12 @@ import {
46
50
  const MAX_TOOL_ITERATIONS = parseInt(process.env.MAX_TOOL_ITERATIONS || '30', 10);
47
51
  const MAX_HISTORY_MESSAGES = 100;
48
52
 
53
+ // Supported image file extensions
54
+ const SUPPORTED_IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg'];
55
+
56
+ // Maximum image file size (20MB) to prevent OOM attacks
57
+ const MAX_IMAGE_FILE_SIZE = 20 * 1024 * 1024;
58
+
49
59
  /**
50
60
  * ProbeAgent class to handle AI interactions with code search capabilities
51
61
  */
@@ -82,8 +92,14 @@ export class ProbeAgent {
82
92
  this.maxResponseTokens = options.maxResponseTokens || parseInt(process.env.MAX_RESPONSE_TOKENS || '0', 10) || null;
83
93
  this.disableMermaidValidation = !!options.disableMermaidValidation;
84
94
 
85
- // Search configuration
86
- this.allowedFolders = options.path ? [options.path] : [process.cwd()];
95
+ // Search configuration - support both path (single) and allowedFolders (array)
96
+ if (options.allowedFolders && Array.isArray(options.allowedFolders)) {
97
+ this.allowedFolders = options.allowedFolders;
98
+ } else if (options.path) {
99
+ this.allowedFolders = [options.path];
100
+ } else {
101
+ this.allowedFolders = [process.cwd()];
102
+ }
87
103
 
88
104
  // API configuration
89
105
  this.clientApiProvider = options.provider || null;
@@ -105,6 +121,10 @@ export class ProbeAgent {
105
121
  // Initialize chat history
106
122
  this.history = [];
107
123
 
124
+ // Initialize image tracking for agentic loop
125
+ this.pendingImages = new Map(); // Map<imagePath, base64Data> to avoid reloading
126
+ this.currentImages = []; // Currently active images for AI calls
127
+
108
128
  // Initialize event emitter for tool execution updates
109
129
  this.events = new EventEmitter();
110
130
 
@@ -173,12 +193,18 @@ export class ProbeAgent {
173
193
  const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
174
194
  const openaiApiKey = process.env.OPENAI_API_KEY;
175
195
  const googleApiKey = process.env.GOOGLE_API_KEY;
196
+ const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID;
197
+ const awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
198
+ const awsRegion = process.env.AWS_REGION;
199
+ const awsSessionToken = process.env.AWS_SESSION_TOKEN;
200
+ const awsApiKey = process.env.AWS_BEDROCK_API_KEY;
176
201
 
177
202
  // Get custom API URLs if provided
178
203
  const llmBaseUrl = process.env.LLM_BASE_URL;
179
204
  const anthropicApiUrl = process.env.ANTHROPIC_API_URL || llmBaseUrl;
180
205
  const openaiApiUrl = process.env.OPENAI_API_URL || llmBaseUrl;
181
206
  const googleApiUrl = process.env.GOOGLE_API_URL || llmBaseUrl;
207
+ const awsBedrockBaseUrl = process.env.AWS_BEDROCK_BASE_URL || llmBaseUrl;
182
208
 
183
209
  // Get model override if provided
184
210
  const modelName = process.env.MODEL_NAME;
@@ -187,7 +213,12 @@ export class ProbeAgent {
187
213
  const forceProvider = this.clientApiProvider || (process.env.FORCE_PROVIDER ? process.env.FORCE_PROVIDER.toLowerCase() : null);
188
214
 
189
215
  if (this.debug) {
190
- console.log(`[DEBUG] Available API keys: Anthropic=${!!anthropicApiKey}, OpenAI=${!!openaiApiKey}, Google=${!!googleApiKey}`);
216
+ const hasAwsCredentials = !!(awsAccessKeyId && awsSecretAccessKey && awsRegion);
217
+ const hasAwsApiKey = !!awsApiKey;
218
+ console.log(`[DEBUG] Available API keys: Anthropic=${!!anthropicApiKey}, OpenAI=${!!openaiApiKey}, Google=${!!googleApiKey}, AWS Bedrock=${hasAwsCredentials || hasAwsApiKey}`);
219
+ if (hasAwsCredentials) console.log(`[DEBUG] AWS credentials: AccessKey=${!!awsAccessKeyId}, SecretKey=${!!awsSecretAccessKey}, Region=${awsRegion}, SessionToken=${!!awsSessionToken}`);
220
+ if (hasAwsApiKey) console.log(`[DEBUG] AWS API Key provided`);
221
+ if (awsBedrockBaseUrl) console.log(`[DEBUG] AWS Bedrock base URL: ${awsBedrockBaseUrl}`);
191
222
  console.log(`[DEBUG] Force provider: ${forceProvider || '(not set)'}`);
192
223
  if (modelName) console.log(`[DEBUG] Model override: ${modelName}`);
193
224
  }
@@ -203,6 +234,9 @@ export class ProbeAgent {
203
234
  } else if (forceProvider === 'google' && googleApiKey) {
204
235
  this.initializeGoogleModel(googleApiKey, googleApiUrl, modelName);
205
236
  return;
237
+ } else if (forceProvider === 'bedrock' && ((awsAccessKeyId && awsSecretAccessKey && awsRegion) || awsApiKey)) {
238
+ this.initializeBedrockModel(awsAccessKeyId, awsSecretAccessKey, awsRegion, awsSessionToken, awsApiKey, awsBedrockBaseUrl, modelName);
239
+ return;
206
240
  }
207
241
  console.warn(`WARNING: Forced provider "${forceProvider}" selected but required API key is missing or invalid! Falling back to auto-detection.`);
208
242
  }
@@ -214,8 +248,10 @@ export class ProbeAgent {
214
248
  this.initializeOpenAIModel(openaiApiKey, openaiApiUrl, modelName);
215
249
  } else if (googleApiKey) {
216
250
  this.initializeGoogleModel(googleApiKey, googleApiUrl, modelName);
251
+ } else if ((awsAccessKeyId && awsSecretAccessKey && awsRegion) || awsApiKey) {
252
+ this.initializeBedrockModel(awsAccessKeyId, awsSecretAccessKey, awsRegion, awsSessionToken, awsApiKey, awsBedrockBaseUrl, modelName);
217
253
  } else {
218
- throw new Error('No API key provided. Please set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY environment variable.');
254
+ throw new Error('No API key provided. Please set ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION), or AWS_BEDROCK_API_KEY environment variables.');
219
255
  }
220
256
  }
221
257
 
@@ -268,6 +304,266 @@ export class ProbeAgent {
268
304
  }
269
305
  }
270
306
 
307
+ /**
308
+ * Initialize AWS Bedrock model
309
+ */
310
+ initializeBedrockModel(accessKeyId, secretAccessKey, region, sessionToken, apiKey, baseURL, modelName) {
311
+ // Build configuration object, only including defined values
312
+ const config = {};
313
+
314
+ // Authentication - prefer API key if provided, otherwise use AWS credentials
315
+ if (apiKey) {
316
+ config.apiKey = apiKey;
317
+ } else if (accessKeyId && secretAccessKey) {
318
+ config.accessKeyId = accessKeyId;
319
+ config.secretAccessKey = secretAccessKey;
320
+ if (sessionToken) {
321
+ config.sessionToken = sessionToken;
322
+ }
323
+ }
324
+
325
+ // Region is required for AWS credentials but optional for API key
326
+ if (region) {
327
+ config.region = region;
328
+ }
329
+
330
+ // Optional base URL
331
+ if (baseURL) {
332
+ config.baseURL = baseURL;
333
+ }
334
+
335
+ this.provider = createAmazonBedrock(config);
336
+ this.model = modelName || 'anthropic.claude-sonnet-4-20250514-v1:0';
337
+ this.apiType = 'bedrock';
338
+
339
+ if (this.debug) {
340
+ const authMethod = apiKey ? 'API Key' : 'AWS Credentials';
341
+ const regionInfo = region ? ` (Region: ${region})` : '';
342
+ const baseUrlInfo = baseURL ? ` (Base URL: ${baseURL})` : '';
343
+ console.log(`Using AWS Bedrock API with model: ${this.model}${regionInfo} [Auth: ${authMethod}]${baseUrlInfo}`);
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Process assistant response content and detect/load image references
349
+ * @param {string} content - The assistant's response content
350
+ * @returns {Promise<void>}
351
+ */
352
+ async processImageReferences(content) {
353
+ if (!content) return;
354
+
355
+ // Enhanced pattern to detect image file mentions in various contexts
356
+ // Looks for: "image", "file", "screenshot", etc. followed by path-like strings with image extensions
357
+ const extensionsPattern = `(?:${SUPPORTED_IMAGE_EXTENSIONS.join('|')})`;
358
+ const imagePatterns = [
359
+ // Direct file path mentions: "./screenshot.png", "/path/to/image.jpg", etc.
360
+ new RegExp(`(?:\\.?\\.\\/)?[^\\s"'<>\\[\\]]+\\\.${extensionsPattern}(?!\\w)`, 'gi'),
361
+ // Contextual mentions: "look at image.png", "the file screenshot.jpg shows"
362
+ new RegExp(`(?:image|file|screenshot|diagram|photo|picture|graphic)\\s*:?\\s*([^\\s"'<>\\[\\]]+\\.${extensionsPattern})(?!\\w)`, 'gi'),
363
+ // Tool result mentions: often contain file paths
364
+ new RegExp(`(?:found|saved|created|generated).*?([^\\s"'<>\\[\\]]+\\.${extensionsPattern})(?!\\w)`, 'gi')
365
+ ];
366
+
367
+ const foundPaths = new Set();
368
+
369
+ // Extract potential image paths using all patterns
370
+ for (const pattern of imagePatterns) {
371
+ let match;
372
+ while ((match = pattern.exec(content)) !== null) {
373
+ // For patterns with capture groups, use the captured path; otherwise use the full match
374
+ const imagePath = match[1] || match[0];
375
+ if (imagePath && imagePath.length > 0) {
376
+ foundPaths.add(imagePath.trim());
377
+ }
378
+ }
379
+ }
380
+
381
+ if (foundPaths.size === 0) return;
382
+
383
+ if (this.debug) {
384
+ console.log(`[DEBUG] Found ${foundPaths.size} potential image references:`, Array.from(foundPaths));
385
+ }
386
+
387
+ // Process each found path
388
+ for (const imagePath of foundPaths) {
389
+ await this.loadImageIfValid(imagePath);
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Load and cache an image if it's valid and accessible
395
+ * @param {string} imagePath - Path to the image file
396
+ * @returns {Promise<boolean>} - True if image was loaded successfully
397
+ */
398
+ async loadImageIfValid(imagePath) {
399
+ try {
400
+ // Skip if already loaded
401
+ if (this.pendingImages.has(imagePath)) {
402
+ if (this.debug) {
403
+ console.log(`[DEBUG] Image already loaded: ${imagePath}`);
404
+ }
405
+ return true;
406
+ }
407
+
408
+ // Security validation: check if path is within any allowed directory
409
+ const allowedDirs = this.allowedFolders && this.allowedFolders.length > 0 ? this.allowedFolders : [process.cwd()];
410
+
411
+ let absolutePath;
412
+ let isPathAllowed = false;
413
+
414
+ // If absolute path, check if it's within any allowed directory
415
+ if (isAbsolute(imagePath)) {
416
+ absolutePath = imagePath;
417
+ isPathAllowed = allowedDirs.some(dir => absolutePath.startsWith(resolve(dir)));
418
+ } else {
419
+ // For relative paths, try resolving against each allowed directory
420
+ for (const dir of allowedDirs) {
421
+ const resolvedPath = resolve(dir, imagePath);
422
+ if (resolvedPath.startsWith(resolve(dir))) {
423
+ absolutePath = resolvedPath;
424
+ isPathAllowed = true;
425
+ break;
426
+ }
427
+ }
428
+ }
429
+
430
+ // Security check: ensure path is within at least one allowed directory
431
+ if (!isPathAllowed) {
432
+ if (this.debug) {
433
+ console.log(`[DEBUG] Image path outside allowed directories: ${imagePath}`);
434
+ }
435
+ return false;
436
+ }
437
+
438
+ // Check if file exists and get file stats
439
+ let fileStats;
440
+ try {
441
+ fileStats = await stat(absolutePath);
442
+ } catch (error) {
443
+ if (this.debug) {
444
+ console.log(`[DEBUG] Image file not found: ${absolutePath}`);
445
+ }
446
+ return false;
447
+ }
448
+
449
+ // Validate file size to prevent OOM attacks
450
+ if (fileStats.size > MAX_IMAGE_FILE_SIZE) {
451
+ if (this.debug) {
452
+ console.log(`[DEBUG] Image file too large: ${absolutePath} (${fileStats.size} bytes, max: ${MAX_IMAGE_FILE_SIZE})`);
453
+ }
454
+ return false;
455
+ }
456
+
457
+ // Validate file extension
458
+ const extension = absolutePath.toLowerCase().split('.').pop();
459
+ if (!SUPPORTED_IMAGE_EXTENSIONS.includes(extension)) {
460
+ if (this.debug) {
461
+ console.log(`[DEBUG] Unsupported image format: ${extension}`);
462
+ }
463
+ return false;
464
+ }
465
+
466
+ // Determine MIME type
467
+ const mimeTypes = {
468
+ 'png': 'image/png',
469
+ 'jpg': 'image/jpeg',
470
+ 'jpeg': 'image/jpeg',
471
+ 'webp': 'image/webp',
472
+ 'gif': 'image/gif',
473
+ 'bmp': 'image/bmp',
474
+ 'svg': 'image/svg+xml'
475
+ };
476
+ const mimeType = mimeTypes[extension];
477
+
478
+ // Read and encode file asynchronously
479
+ const fileBuffer = await readFile(absolutePath);
480
+ const base64Data = fileBuffer.toString('base64');
481
+ const dataUrl = `data:${mimeType};base64,${base64Data}`;
482
+
483
+ // Cache the loaded image
484
+ this.pendingImages.set(imagePath, dataUrl);
485
+
486
+ if (this.debug) {
487
+ console.log(`[DEBUG] Successfully loaded image: ${imagePath} (${fileBuffer.length} bytes)`);
488
+ }
489
+
490
+ return true;
491
+ } catch (error) {
492
+ if (this.debug) {
493
+ console.log(`[DEBUG] Failed to load image ${imagePath}: ${error.message}`);
494
+ }
495
+ return false;
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Get all currently loaded images as an array for AI model consumption
501
+ * @returns {Array<string>} - Array of base64 data URLs
502
+ */
503
+ getCurrentImages() {
504
+ return Array.from(this.pendingImages.values());
505
+ }
506
+
507
+ /**
508
+ * Clear loaded images (useful for new conversations)
509
+ */
510
+ clearLoadedImages() {
511
+ this.pendingImages.clear();
512
+ this.currentImages = [];
513
+ if (this.debug) {
514
+ console.log('[DEBUG] Cleared all loaded images');
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Prepare messages for AI consumption, adding images to the latest user message if available
520
+ * @param {Array} messages - Current conversation messages
521
+ * @returns {Array} - Messages formatted for AI SDK with potential image content
522
+ */
523
+ prepareMessagesWithImages(messages) {
524
+ const loadedImages = this.getCurrentImages();
525
+
526
+ // If no images loaded, return messages as-is
527
+ if (loadedImages.length === 0) {
528
+ return messages;
529
+ }
530
+
531
+ // Clone messages to avoid mutating the original
532
+ const messagesWithImages = [...messages];
533
+
534
+ // Find the last user message to attach images to
535
+ const lastUserMessageIndex = messagesWithImages.map(m => m.role).lastIndexOf('user');
536
+
537
+ if (lastUserMessageIndex === -1) {
538
+ if (this.debug) {
539
+ console.log('[DEBUG] No user messages found to attach images to');
540
+ }
541
+ return messages;
542
+ }
543
+
544
+ const lastUserMessage = messagesWithImages[lastUserMessageIndex];
545
+
546
+ // Convert to multimodal format if we have images
547
+ if (typeof lastUserMessage.content === 'string') {
548
+ messagesWithImages[lastUserMessageIndex] = {
549
+ ...lastUserMessage,
550
+ content: [
551
+ { type: 'text', text: lastUserMessage.content },
552
+ ...loadedImages.map(imageData => ({
553
+ type: 'image',
554
+ image: imageData
555
+ }))
556
+ ]
557
+ };
558
+
559
+ if (this.debug) {
560
+ console.log(`[DEBUG] Added ${loadedImages.length} images to the latest user message`);
561
+ }
562
+ }
563
+
564
+ return messagesWithImages;
565
+ }
566
+
271
567
  /**
272
568
  * Initialize mock model for testing
273
569
  */
@@ -695,9 +991,12 @@ When troubleshooting:
695
991
  try {
696
992
  // Wrap AI request with tracing if available
697
993
  const executeAIRequest = async () => {
994
+ // Prepare messages with potential image content
995
+ const messagesForAI = this.prepareMessagesWithImages(currentMessages);
996
+
698
997
  const result = await streamText({
699
998
  model: this.provider(this.model),
700
- messages: currentMessages,
999
+ messages: messagesForAI,
701
1000
  maxTokens: maxResponseTokens,
702
1001
  temperature: 0.3,
703
1002
  });
@@ -741,6 +1040,11 @@ When troubleshooting:
741
1040
  console.log(`[DEBUG] Assistant response (${assistantResponseContent.length} chars): ${assistantPreview}`);
742
1041
  }
743
1042
 
1043
+ // Process image references in assistant response for next iteration
1044
+ if (assistantResponseContent) {
1045
+ await this.processImageReferences(assistantResponseContent);
1046
+ }
1047
+
744
1048
  // Parse tool call from response with valid tools list
745
1049
  const validTools = [
746
1050
  'search', 'query', 'extract', 'listFiles', 'searchFiles', 'attempt_completion'
@@ -819,8 +1123,12 @@ When troubleshooting:
819
1123
  } else if (this.toolImplementations[toolName]) {
820
1124
  // Execute native tool
821
1125
  try {
822
- // Add sessionId to params for tool execution
823
- const toolParams = { ...params, sessionId: this.sessionId };
1126
+ // Add sessionId and workingDirectory to params for tool execution
1127
+ const toolParams = {
1128
+ ...params,
1129
+ sessionId: this.sessionId,
1130
+ workingDirectory: (this.allowedFolders && this.allowedFolders[0]) || process.cwd()
1131
+ };
824
1132
 
825
1133
  // Emit tool start event
826
1134
  this.events.emit('toolCall', {
@@ -898,11 +1206,20 @@ When troubleshooting:
898
1206
 
899
1207
  // Add assistant response and tool result to conversation
900
1208
  currentMessages.push({ role: 'assistant', content: assistantResponseContent });
1209
+
1210
+ const toolResultContent = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult, null, 2);
1211
+ const toolResultMessage = `<tool_result>\n${toolResultContent}\n</tool_result>`;
1212
+
901
1213
  currentMessages.push({
902
1214
  role: 'user',
903
- content: `<tool_result>\n${typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult, null, 2)}\n</tool_result>`
1215
+ content: toolResultMessage
904
1216
  });
905
1217
 
1218
+ // Process tool result for image references
1219
+ if (toolResultContent) {
1220
+ await this.processImageReferences(toolResultContent);
1221
+ }
1222
+
906
1223
  if (this.debug) {
907
1224
  console.log(`[DEBUG] Tool ${toolName} executed successfully. Result length: ${typeof toolResult === 'string' ? toolResult.length : JSON.stringify(toolResult).length}`);
908
1225
  }
@@ -964,8 +1281,10 @@ Remember: Use proper XML format with BOTH opening and closing tags:
964
1281
  <parameter>value</parameter>
965
1282
  </tool_name>
966
1283
 
967
- Or for quick completion if your previous response was already correct:
968
- <attempt_complete>`;
1284
+ Or for quick completion if your previous response was already correct and complete:
1285
+ <attempt_complete>
1286
+
1287
+ IMPORTANT: When using <attempt_complete>, this must be the ONLY content in your response. No additional text, explanations, or other content should be included. This tag signals to reuse your previous response as the final answer.`;
969
1288
  }
970
1289
 
971
1290
  currentMessages.push({
@@ -1483,4 +1802,4 @@ Convert your previous response content into actual JSON data that follows this s
1483
1802
  console.log(`[DEBUG] Agent cancelled for session ${this.sessionId}`);
1484
1803
  }
1485
1804
  }
1486
- }
1805
+ }
@@ -112,6 +112,7 @@ function parseArgs() {
112
112
  acp: false,
113
113
  question: null,
114
114
  path: null,
115
+ allowedFolders: null,
115
116
  prompt: null,
116
117
  systemPrompt: null,
117
118
  schema: null,
@@ -145,6 +146,8 @@ function parseArgs() {
145
146
  config.allowEdit = true;
146
147
  } else if (arg === '--path' && i + 1 < args.length) {
147
148
  config.path = args[++i];
149
+ } else if (arg === '--allowed-folders' && i + 1 < args.length) {
150
+ config.allowedFolders = args[++i].split(',').map(dir => dir.trim());
148
151
  } else if (arg === '--prompt' && i + 1 < args.length) {
149
152
  config.prompt = args[++i];
150
153
  } else if (arg === '--system-prompt' && i + 1 < args.length) {
@@ -201,6 +204,7 @@ Usage:
201
204
 
202
205
  Options:
203
206
  --path <dir> Search directory (default: current)
207
+ --allowed-folders <dirs> Comma-separated list of allowed directories for file operations
204
208
  --prompt <type> Persona: code-explorer, engineer, code-review, support, architect
205
209
  --system-prompt <text|file> Custom system prompt (text or file path)
206
210
  --schema <schema|file> Output schema (JSON, XML, any format - text or file path)
@@ -266,6 +270,9 @@ class ProbeAgentMcpServer {
266
270
  }
267
271
  );
268
272
 
273
+ // Don't initialize AI agent on startup - lazy initialize when needed
274
+ this.agent = null;
275
+
269
276
  this.setupToolHandlers();
270
277
  this.server.onerror = (error) => console.error('[MCP Error]', error);
271
278
  process.on('SIGINT', async () => {
@@ -293,17 +300,22 @@ class ProbeAgentMcpServer {
293
300
  tools: [
294
301
  {
295
302
  name: 'search_code',
296
- description: "Search code and answer questions about the codebase using an AI agent. This tool provides intelligent responses based on code analysis.",
303
+ description: "AI agent that answers free-form questions about codebases. Ask detailed questions in natural language.",
297
304
  inputSchema: {
298
305
  type: 'object',
299
306
  properties: {
300
307
  query: {
301
308
  type: 'string',
302
- description: 'The question or request about the codebase.',
309
+ description: 'A detailed, free-form question about the codebase in natural language. Be specific and descriptive. Example: "How does the authentication system work and where is user session management implemented?"',
303
310
  },
304
311
  path: {
305
312
  type: 'string',
306
- description: 'Optional path to the directory to search in. Defaults to current directory.',
313
+ description: 'Absolute path to the directory to search in (e.g., "/Users/username/projects/myproject").',
314
+ },
315
+ allowed_folders: {
316
+ type: 'array',
317
+ items: { type: 'string' },
318
+ description: 'Optional list of allowed directories for file operations. Defaults to current directory if not specified.',
307
319
  },
308
320
  prompt: {
309
321
  type: 'string',
@@ -312,34 +324,6 @@ class ProbeAgentMcpServer {
312
324
  system_prompt: {
313
325
  type: 'string',
314
326
  description: 'Optional custom system prompt (text or file path).',
315
- },
316
- provider: {
317
- type: 'string',
318
- description: 'Optional AI provider to force: anthropic, openai, google.',
319
- },
320
- model: {
321
- type: 'string',
322
- description: 'Optional model name override.',
323
- },
324
- allow_edit: {
325
- type: 'boolean',
326
- description: 'Enable code modification capabilities.',
327
- },
328
- max_iterations: {
329
- type: 'number',
330
- description: 'Maximum number of tool iterations (default: 30).',
331
- },
332
- max_response_tokens: {
333
- type: 'number',
334
- description: 'Maximum tokens for AI response (overrides model defaults).',
335
- },
336
- schema: {
337
- type: 'string',
338
- description: 'Optional output schema (JSON, XML, or any format - text or file path).',
339
- },
340
- no_mermaid_validation: {
341
- type: 'boolean',
342
- description: 'Disable automatic mermaid diagram validation and fixing.',
343
327
  }
344
328
  },
345
329
  required: ['query']
@@ -393,20 +377,29 @@ class ProbeAgentMcpServer {
393
377
  }
394
378
  }
395
379
 
396
- // Create agent with configuration
397
- const agentConfig = {
398
- path: args.path || process.cwd(),
399
- promptType: args.prompt || 'code-explorer',
400
- customPrompt: systemPrompt,
401
- provider: args.provider,
402
- model: args.model,
403
- allowEdit: !!args.allow_edit,
404
- debug: process.env.DEBUG === '1',
405
- maxResponseTokens: args.max_response_tokens,
406
- disableMermaidValidation: !!args.no_mermaid_validation
407
- };
380
+ // Lazy initialize AI agent only when tool is called
381
+ if (!this.agent) {
382
+ if (process.env.DEBUG === '1') {
383
+ console.error('[DEBUG] Initializing AI agent on first MCP tool call');
384
+ }
385
+
386
+ // Create agent with configuration
387
+ const agentConfig = {
388
+ path: args.path || (args.allowed_folders && args.allowed_folders[0]) || process.cwd(),
389
+ promptType: args.prompt || 'code-explorer',
390
+ customPrompt: systemPrompt,
391
+ provider: args.provider,
392
+ model: args.model,
393
+ allowEdit: !!args.allow_edit,
394
+ debug: process.env.DEBUG === '1',
395
+ maxResponseTokens: args.max_response_tokens,
396
+ disableMermaidValidation: !!args.no_mermaid_validation
397
+ };
398
+
399
+ this.agent = new ProbeAgent(agentConfig);
400
+ }
408
401
 
409
- const agent = new ProbeAgent(agentConfig);
402
+ const agent = this.agent;
410
403
  let result = await agent.answer(query, [], { schema });
411
404
 
412
405
  // If schema is provided, make a follow-up request to format the output
@@ -479,7 +472,7 @@ class ProbeAgentMcpServer {
479
472
  }
480
473
 
481
474
  // Get token usage for debugging
482
- const tokenUsage = agent.getTokenUsage();
475
+ const tokenUsage = this.agent.getTokenUsage();
483
476
  console.error(`Token usage: ${JSON.stringify(tokenUsage)}`);
484
477
 
485
478
  return {
@@ -631,6 +624,7 @@ async function main() {
631
624
  // Create and configure agent
632
625
  const agentConfig = {
633
626
  path: config.path,
627
+ allowedFolders: config.allowedFolders,
634
628
  promptType: config.prompt,
635
629
  customPrompt: systemPrompt,
636
630
  allowEdit: config.allowEdit,
@@ -207,14 +207,24 @@ export function createWrappedTools(baseTools) {
207
207
  // Simple file listing tool
208
208
  export const listFilesTool = {
209
209
  execute: async (params) => {
210
- const { directory = '.' } = params;
210
+ const { directory = '.', workingDirectory } = params;
211
+
212
+ // Use the provided working directory, or fall back to process.cwd()
213
+ const baseCwd = workingDirectory || process.cwd();
214
+
215
+ // Security: Validate path to prevent traversal attacks
216
+ const secureBaseDir = path.resolve(baseCwd);
217
+ const targetDir = path.resolve(secureBaseDir, directory);
218
+ if (!targetDir.startsWith(secureBaseDir + path.sep) && targetDir !== secureBaseDir) {
219
+ throw new Error('Path traversal attempt detected. Access denied.');
220
+ }
211
221
 
212
222
  try {
213
223
  const files = await listFilesByLevel({
214
- directory,
224
+ directory: targetDir,
215
225
  maxFiles: 100,
216
226
  respectGitignore: !process.env.PROBE_NO_GITIGNORE || process.env.PROBE_NO_GITIGNORE === '',
217
- cwd: process.cwd()
227
+ cwd: secureBaseDir
218
228
  });
219
229
 
220
230
  return files;
@@ -227,15 +237,23 @@ export const listFilesTool = {
227
237
  // Simple file search tool
228
238
  export const searchFilesTool = {
229
239
  execute: async (params) => {
230
- const { pattern, directory = '.', recursive = true } = params;
240
+ const { pattern, directory = '.', recursive = true, workingDirectory } = params;
231
241
 
232
242
  if (!pattern) {
233
243
  throw new Error('Pattern is required for file search');
234
244
  }
235
245
 
246
+ // Security: Validate path to prevent traversal attacks
247
+ const baseCwd = workingDirectory || process.cwd();
248
+ const secureBaseDir = path.resolve(baseCwd);
249
+ const targetDir = path.resolve(secureBaseDir, directory);
250
+ if (!targetDir.startsWith(secureBaseDir + path.sep) && targetDir !== secureBaseDir) {
251
+ throw new Error('Path traversal attempt detected. Access denied.');
252
+ }
253
+
236
254
  try {
237
255
  const options = {
238
- cwd: directory,
256
+ cwd: targetDir,
239
257
  ignore: ['node_modules/**', '.git/**'],
240
258
  absolute: false
241
259
  };