/**
* Bulk Operations Utility for Local File Tools
*
* This module provides utilities for processing and formatting bulk query operations
* for local file system tools. It follows the pattern established in octocode-mcp
* but simplified for local operations.
*
* ## Public API
* - `executeBulkOperation()` - Primary function for tools to process bulk queries
* - `processBulkQueries()` - Lower-level parallel query processor
* - `createErrorResult()` - Helper for creating error results
*
* @module bulkOperations
*/
import { type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { settleAll } from './promiseUtils.js';
import { createResponseFormat } from './responses.js';
import { validateBulkTokenLimit } from './tokenValidation.js';
// ============================================================================
// TYPE DEFINITIONS
// ============================================================================
/**
* Base result interface with status field
*/
interface BaseResult {
status: 'hasResults' | 'empty' | 'error';
researchGoal?: string;
reasoning?: string;
error?: string;
hints?: readonly string[];
}
/**
* Configuration for bulk response formatting
*/
interface BulkResponseConfig {
toolName: string;
}
/**
* Flat query result structure for response
* Clean structure: only status and data (no query echo, no duplicated fields)
*/
interface FlatQueryResult<TResult> {
status: 'hasResults' | 'empty' | 'error';
data: TResult;
}
/**
* Bulk operation response structure
* Clean structure: no duplicate hints at top level (they're in each result's data)
*/
interface BulkOperationResponse<TResult> {
instructions: string;
results: Array<FlatQueryResult<TResult>>;
summary: {
total: number;
hasResults: number;
empty: number;
errors: number;
};
[key: string]: unknown; // Allow additional properties for ToolResponse compatibility
}
// ============================================================================
// EXPORTED FUNCTIONS
// ============================================================================
/**
* Execute bulk queries and format the response in a single operation.
* This is the primary function that tools should use.
*
* @param queries - Array of query objects to process
* @param processor - Async function that processes each query
* @param config - Configuration for response formatting (toolName)
* @returns Formatted MCP CallToolResult ready to send to client
*
* @example
* return executeBulkOperation(
* queries,
* searchContent,
* { toolName: TOOL_NAMES.LOCAL_SEARCH_CONTENT }
* );
*/
export async function executeBulkOperation<
TQuery extends object,
TResult extends BaseResult,
>(
queries: Array<TQuery>,
processor: (query: TQuery) => Promise<TResult>,
config: BulkResponseConfig
): Promise<CallToolResult> {
// Process all queries in parallel
const results = await processBulkQueries<TQuery, TResult>(queries, processor);
// Format response
return formatBulkResponse<TResult>(results, config);
}
/**
* Processes multiple queries in parallel with error isolation.
* Lower-level function - prefer using executeBulkOperation() instead.
*
* @param queries - Array of query objects to process
* @param processFn - Async function that processes each query
* @param options - Optional configuration for error handling
* @returns Array of results (including error results)
*/
export async function processBulkQueries<TQuery, TResult extends BaseResult>(
queries: TQuery[],
processFn: (query: TQuery) => Promise<TResult>,
options: {
concurrency?: number;
continueOnError?: boolean;
} = {}
): Promise<TResult[]> {
const { continueOnError = true } = options;
// Execute all queries in parallel
const promises = queries.map((query) => processFn(query));
const results = await settleAll(promises);
// Process results
const finalResults: TResult[] = [];
for (const [index, result] of results.entries()) {
if (result.status === 'fulfilled') {
finalResults.push(result.value);
} else {
if (!continueOnError) {
throw result.reason;
}
// Create error result from failed promise
const errorResult = createErrorResult<TResult>(
result.reason,
queries[index] as Partial<TResult>
);
finalResults.push(errorResult);
}
}
return finalResults;
}
/**
* Creates an error result for failed queries.
* Helper function for manual error handling.
*
* @param error - Error object or message
* @param baseData - Base data to include in the error result (like query fields)
* @returns Error result object
*/
export function createErrorResult<T extends BaseResult>(
error: unknown,
baseData: Partial<T> = {}
): T {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
...baseData,
status: 'error',
error: errorMessage,
} as T;
}
// ============================================================================
// INTERNAL FUNCTIONS
// ============================================================================
/**
* Format bulk query results into an MCP CallToolResult.
* Internal function used by executeBulkOperation().
*/
function formatBulkResponse<TResult extends BaseResult>(
results: Array<TResult>,
config: BulkResponseConfig
): CallToolResult {
// Count status types
let hasResultsCount = 0;
let emptyCount = 0;
let errorCount = 0;
// Create flat query results (clean structure: no query echo, no duplication)
const flatResults: Array<FlatQueryResult<TResult>> = [];
for (let i = 0; i < results.length; i++) {
const result = results[i];
// Create flat result with only status and complete data
// All fields (researchGoal, reasoning, hints) stay in data where they belong
flatResults.push({
status: result.status,
data: result,
});
// Count statuses
if (result.status === 'hasResults') {
hasResultsCount++;
} else if (result.status === 'empty') {
emptyCount++;
} else if (result.status === 'error') {
errorCount++;
}
}
// Build instructions
const counts = [];
if (hasResultsCount > 0) counts.push(`${hasResultsCount} with results`);
if (emptyCount > 0) counts.push(`${emptyCount} empty`);
if (errorCount > 0) counts.push(`${errorCount} failed`);
const instructions = [
`Bulk response from ${config.toolName} with ${flatResults.length} queries: ${counts.join(', ')}.`,
'Each result includes status and data with hints.',
hasResultsCount > 0
? 'Review results with status="hasResults" for found data.'
: null,
emptyCount > 0
? 'Review results with status="empty" for no-match scenarios.'
: null,
errorCount > 0 ? 'Review results with status="error" for failures.' : null,
]
.filter(Boolean)
.join('\n');
// Create response structure (clean: no duplicate hints at top level)
const responseData: BulkOperationResponse<TResult> = {
instructions,
results: flatResults,
summary: {
total: flatResults.length,
hasResults: hasResultsCount,
empty: emptyCount,
errors: errorCount,
},
};
// Format response using octocode-utils for YAML conversion
// Priority ordering: instructions first, then results, then summary
const formattedText = createResponseFormat(responseData, [
'instructions',
'results',
'summary',
'status',
'data',
]);
// CRITICAL: Validate final response size to prevent exceeding MCP 25K token limit
const validation = validateBulkTokenLimit(
formattedText,
config.toolName,
flatResults.length
);
if (!validation.isValid) {
// Response is too large - return error with guidance
const errorResponse = {
instructions: `ERROR: Bulk response exceeds MCP token limit`,
error: validation.error,
summary: {
total: flatResults.length,
hasResults: hasResultsCount,
empty: emptyCount,
errors: errorCount,
},
hints: validation.hints || [],
};
return {
content: [
{
type: 'text' as const,
text: createResponseFormat(errorResponse, ['instructions', 'error', 'summary', 'hints']),
},
],
isError: true,
};
}
return {
content: [
{
type: 'text' as const,
text: formattedText,
},
],
isError: false,
};
}
// Note: Removed extractResultData, extractField, and filterQueryFields
// These are no longer needed with the clean structure that keeps all data intact