UNPKG

sarvam-mcp

Version:

An MCP server exposing Sarvam AI tools and a documentation retriever.

407 lines (357 loc) 20 kB
import fs from 'fs'; import path_module from 'path'; // Using path_module to avoid conflict with path variable import { fileURLToPath } from 'url'; // ADDED /** * Retrieves the full content of the most relevant Sarvam AI markdown documentation file * based on a search term and optional documentation area. * * @param {Object} args - Arguments for the documentation file retrieval. * @param {string} args.search_term - Keywords, a topic description, or a partial/full filename. * @param {string} [args.doc_area] - Optional. Specific documentation area (e.g., 'api-ref', 'cookbook'). * @param {Object} context - The execution context (passed by mcpServer, may not be used by this func for fs ops). * @returns {Promise<Object>} - An object containing the retrieved file path, content, and status. */ const executeSarvamDocsFileRetrieval = async ({ search_term, doc_area }, context) => { // console.log(`Executing Sarvam Docs File Retrieval with search_term: "${search_term}", doc_area: "${doc_area}"`); if (!context) { // console.warn('Warning: Tool execution context was not provided. This tool might rely on it for other operations.'); } // --- MODIFIED PATH RESOLUTION --- const __filename = fileURLToPath(import.meta.url); const __dirname = path_module.dirname(__filename); // projectRoot is two levels up from the current script's directory (tools/sarvam-api/) const projectRoot = path_module.resolve(__dirname, '../../'); const docsRootDir = path_module.join(projectRoot, 'docs'); // --- END MODIFIED PATH RESOLUTION --- const allCandidateFiles = []; const searchPaths = []; const defaultSearchSubDirs = ['api-ref', 'cookbook', 'docs-section']; // These are subdirs of docsRootDir if (doc_area) { // If doc_area is provided, treat it as a subdirectory relative to docsRootDir // or a path that might start with 'docs/' relative to projectRoot if (doc_area.startsWith('docs/')) { // e.g. user provided "docs/custom-area" searchPaths.push(path_module.join(projectRoot, doc_area)); } else { // e.g. user provided "api-ref" or "custom-area" searchPaths.push(path_module.join(docsRootDir, doc_area)); } } else { for (const subDir of defaultSearchSubDirs) { searchPaths.push(path_module.join(docsRootDir, subDir)); } } // console.log('Searching in absolute paths:', searchPaths); for (const absoluteDocPath of searchPaths) { try { if (!fs.existsSync(absoluteDocPath) || !fs.lstatSync(absoluteDocPath).isDirectory()){ // console.warn(`Search path ${absoluteDocPath} does not exist or is not a directory. Skipping.`); continue; } const filesInDir = fs.readdirSync(absoluteDocPath); // Store paths relative to docsRootDir for easier handling later const pathSuffix = path_module.relative(docsRootDir, absoluteDocPath); const mdFiles = filesInDir .filter(file => file.endsWith('.md')) .map(file => path_module.join(pathSuffix, file).replace(/\\/g, '/')); // Normalize slashes allCandidateFiles.push(...mdFiles); } catch (error) { // console.error(`Error reading directory ${absoluteDocPath}:`, error.message); if (doc_area) { return { retrieved_file_path: null, file_content: null, status_message: `Failed to list directory for specified doc_area: ${absoluteDocPath}. Error: ${error.message}`, error_message: error.message }; } // console.warn(`Could not list directory ${absoluteDocPath}, continuing...`); } } if (allCandidateFiles.length === 0) { return { retrieved_file_path: null, file_content: null, status_message: `No .md files found in the searched documentation areas: ${searchPaths.map(p => path_module.relative(projectRoot, p)).join(', ')}. Searched absolute paths: ${searchPaths.join(', ')}`, error_message: null }; } const uniqueCandidateFiles = [...new Set(allCandidateFiles)]; // console.log('Unique candidate .md files (relative to docs root):', uniqueCandidateFiles); const normalizedSearchTerm = search_term.toLowerCase().trim(); let bestMatch = null; // --- MODIFICATION START: Enhanced Keyword Processing & Core Term Identification --- const coreTechTerms = ["text-to-speech", "speech-to-text", "call-analytics", "transliterate", "translate", "language-identification", "tts", "stt"]; // Example core terms/acronyms let searchKeywords = []; let coreSearchKeywords = []; let secondarySearchKeywords = []; const lowerCaseSearchTerm = normalizedSearchTerm; // Already lowercased and trimmed // Identify core terms present in the search query for (const coreTerm of coreTechTerms) { if (lowerCaseSearchTerm.includes(coreTerm)) { coreSearchKeywords.push(coreTerm); } } // Create a regex to split by spaces or by core terms to preserve them let splitRegex; if (coreSearchKeywords.length > 0) { // Escape core keywords for regex and join with | to split by them but keep them const escapedCoreKeywords = coreSearchKeywords.map(kw => kw.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')); // Regex to split by space or capture core keywords // This will result in an array that includes the core keywords as separate elements, // and parts of the string split by these keywords or spaces. splitRegex = new RegExp(`(${escapedCoreKeywords.join('|')})|\\s+`, 'g'); searchKeywords = lowerCaseSearchTerm.split(splitRegex).filter(k => k && k.trim().length > 0); } else { searchKeywords = lowerCaseSearchTerm.split(/\s+/).filter(k => k && k.trim().length > 0); } // Refine searchKeywords: remove duplicates and assign to core/secondary const uniqueSearchKeywords = [...new Set(searchKeywords)]; secondarySearchKeywords = uniqueSearchKeywords.filter(k => !coreSearchKeywords.includes(k) && k.length > 1); // Avoid single characters as secondary unless part of a core term if (coreSearchKeywords.length === 0 && secondarySearchKeywords.length === 0 && lowerCaseSearchTerm.length > 0) { secondarySearchKeywords.push(lowerCaseSearchTerm); // Fallback for terms not matching core/secondary logic } // console.log('Original Search Term:', normalizedSearchTerm); // console.log('Core Search Keywords:', coreSearchKeywords); // console.log('Secondary Search Keywords:', secondarySearchKeywords); // --- END MODIFICATION --- // --- File Selection Logic (paths are relative to docsRootDir) --- // Strategy 1: Filename matching (prioritized) if (normalizedSearchTerm.endsWith('.md')) { const exactMatchByPath = uniqueCandidateFiles.find(fileRelToDocs => fileRelToDocs.toLowerCase() === normalizedSearchTerm || fileRelToDocs.toLowerCase().endsWith(`/${normalizedSearchTerm}`)); if (exactMatchByPath) { bestMatch = { file: exactMatchByPath, score: Infinity, type: 'exact_filename_match' }; // console.log('Found exact filename match (relative to docs root):', bestMatch.file); } else { const justTheFilename = normalizedSearchTerm.substring(normalizedSearchTerm.lastIndexOf('/') + 1); const exactMatchBySimpleName = uniqueCandidateFiles.find(fileRelToDocs => path_module.basename(fileRelToDocs).toLowerCase() === justTheFilename); if (exactMatchBySimpleName){ bestMatch = { file: exactMatchBySimpleName, score: Infinity, type: 'exact_filename_match' }; // console.log('Found exact filename match (by simple name, relative to docs root):', bestMatch.file); } } } // Strategy 2: Keyword matching in filenames and paths if (!bestMatch) { let scoredFiles = uniqueCandidateFiles.map(fileRelToDocs => { const filename = path_module.basename(fileRelToDocs).toLowerCase(); const filePath = fileRelToDocs.toLowerCase(); let score = 0; let matchDebug = []; // Increased weight for core keywords for (const keyword of coreSearchKeywords) { if (filename.includes(keyword)) { score += 30; // Higher score for core keyword in filename matchDebug.push(`core_fn: ${keyword}`); } if (filePath.includes(keyword) && !filename.includes(keyword)) { // in path but not filename score += 15; // Medium score for core keyword in path matchDebug.push(`core_path: ${keyword}`); } } for (const keyword of secondarySearchKeywords) { if (filename.includes(keyword)) { score += 10; // Standard score for secondary keyword in filename matchDebug.push(`sec_fn: ${keyword}`); } if (filePath.includes(keyword) && !filename.includes(keyword)) { // in path but not filename score += 5; // Lower score for secondary keyword in path matchDebug.push(`sec_path: ${keyword}`); } } // Bonus for filename directly containing a large part of the original search term (normalized) // e.g. search "text to speech api", filename "text-to-speech.md" if (!normalizedSearchTerm.endsWith('.md')) { let directHitBonus = 0; if (filename.replace(/\.md$/, "").includes(normalizedSearchTerm.replace(/\s+/g, "-"))) { // e.g. tts-integration matches tts integration directHitBonus = 25 * (coreSearchKeywords.length + 1); // Higher bonus if core terms involved matchDebug.push(`direct_hit_normalized: ${normalizedSearchTerm.replace(/\s+/g, "-")}`); } else if (filename.includes(normalizedSearchTerm)) { // Less likely for multi-word, but possible directHitBonus = 20 * (coreSearchKeywords.length + 1); matchDebug.push(`direct_hit_raw: ${normalizedSearchTerm}`); } score += directHitBonus; } // Proximity bonus for core keywords in filename (simple version) if (coreSearchKeywords.length > 1) { let coreKeywordsInFilename = coreSearchKeywords.filter(kw => filename.includes(kw)); if (coreKeywordsInFilename.length === coreSearchKeywords.length) { // All core keywords present score += 20; matchDebug.push('all_core_fn_present'); } else if (coreKeywordsInFilename.length > 1) { score += 10 * coreKeywordsInFilename.length; // Bonus for multiple core keywords matchDebug.push('multiple_core_fn_present'); } } // console.log(`File: ${fileRelToDocs}, Score: ${score}, Debug: ${matchDebug.join(', ')}`); return { file: fileRelToDocs, score, type: 'keyword_filename_path_match' }; }); scoredFiles = scoredFiles.filter(f => f.score > 0); scoredFiles.sort((a, b) => b.score - a.score); if (scoredFiles.length > 0) { bestMatch = scoredFiles[0]; // console.log('Top filename/path keyword match (relative to docs root):', bestMatch.file, 'Score:', bestMatch.score); } } // Strategy 3: Keyword matching in file content const weakFilenameMatchScore = 40; // Adjusted threshold if (!bestMatch || bestMatch.score < weakFilenameMatchScore) { // console.log('Filename match was weak or non-existent, proceeding to content search.'); let contentScoredFiles = []; const filesToSearchContent = bestMatch ? [bestMatch.file, ...uniqueCandidateFiles.filter(f => f !== bestMatch.file)] : uniqueCandidateFiles; for (const fileRelToDocs of filesToSearchContent) { try { const absoluteFilePath = path_module.join(docsRootDir, fileRelToDocs); // Path relative to docsRootDir if (!fs.existsSync(absoluteFilePath)) { // console.warn(`Content search: File ${absoluteFilePath} not found, skipping.`); continue; } const fileContent = fs.readFileSync(absoluteFilePath, 'utf-8').toLowerCase(); let contentScore = 0; let contentMatchDebug = []; // Extract titles/headings (lines starting with #) const headings = fileContent.split('\\n').filter(line => line.startsWith('#')).map(line => line.replace(/#/g, '').trim()); for (const keyword of coreSearchKeywords) { if (fileContent.includes(keyword)) { contentScore += 10; // Higher base score for core keyword in content contentMatchDebug.push(`core_content: ${keyword}`); } for (const heading of headings) { if (heading.includes(keyword)) { contentScore += 25; // Significant bonus for core keyword in heading contentMatchDebug.push(`core_heading: ${keyword}`); } } } for (const keyword of secondarySearchKeywords) { if (fileContent.includes(keyword)) { contentScore += 2; // Standard score for secondary keyword contentMatchDebug.push(`sec_content: ${keyword}`); } for (const heading of headings) { if (heading.includes(keyword)) { contentScore += 5; // Bonus for secondary keyword in heading contentMatchDebug.push(`sec_heading: ${keyword}`); } } } // Bonus for the full normalized search term appearing in content if (fileContent.includes(normalizedSearchTerm)) { contentScore += 15 * (coreSearchKeywords.length + 1); // Weighted by core keyword presence contentMatchDebug.push(`full_term_content: ${normalizedSearchTerm}`); } for (const heading of headings) { if (heading.includes(normalizedSearchTerm)) { contentScore += 30 * (coreSearchKeywords.length +1); // Large bonus for full term in heading contentMatchDebug.push(`full_term_heading: ${normalizedSearchTerm}`); } } // Simple proximity: if multiple core keywords are present in the content if (coreSearchKeywords.length > 1) { const coreKeywordsInContent = coreSearchKeywords.filter(kw => fileContent.includes(kw)); if (coreKeywordsInContent.length === coreSearchKeywords.length) { contentScore += 20; contentMatchDebug.push('all_core_content_present'); } else if (coreKeywordsInContent.length > 0) { contentScore += 5 * coreKeywordsInContent.length; contentMatchDebug.push('multiple_core_content_present'); } } // console.log(`Content File: ${fileRelToDocs}, Content Score: ${contentScore}, Debug: ${contentMatchDebug.join(', ')}`); if (contentScore > 0) { let existingScore = 0; let previousMatchType = 'content_only'; if (bestMatch && fileRelToDocs === bestMatch.file) { existingScore = bestMatch.score; previousMatchType = bestMatch.type; } // Combine scores: Make content score influential but don't let it completely overshadow a strong filename match unless content match is very strong. // If filename match was already decent, content score serves as a booster/confirmer. // If filename match was weak/non-existent, content score is primary. let combinedScore = existingScore; if (existingScore < weakFilenameMatchScore) { // If filename match was weak combinedScore += contentScore; // Add full content score } else { // Filename match was decent combinedScore += contentScore * 0.5; // Add a portion of content score as a booster } contentScoredFiles.push({ file: fileRelToDocs, score: combinedScore, type: `${previousMatchType}+content_match` }); } } catch (err) { // console.warn(`Could not read or score content for ${fileRelToDocs}: ${err.message}`); } } if (contentScoredFiles.length > 0) { contentScoredFiles.sort((a, b) => b.score - a.score); const topContentMatch = contentScoredFiles[0]; if (!bestMatch || topContentMatch.score > bestMatch.score || (topContentMatch.score === bestMatch.score && topContentMatch.type.includes('content'))) { bestMatch = topContentMatch; // console.log('Top content match selected (relative to docs root):', bestMatch.file, 'Score:', bestMatch.score, 'Type:', bestMatch.type); } } } if (!bestMatch && uniqueCandidateFiles.length === 1 && (coreSearchKeywords.length > 0 || secondarySearchKeywords.length >0 )) { // Ensure there was some search attempt bestMatch = { file: uniqueCandidateFiles[0], score: 1, type: 'single_candidate_fallback' }; // console.log('Only one candidate file and no strong matches, selecting it as a fallback (relative to docs root):', bestMatch.file); } if (!bestMatch) { return { retrieved_file_path: null, file_content: null, status_message: `No relevant file found for "${search_term}" in areas: ${searchPaths.map(p => path_module.relative(projectRoot, p)).join(', ')}. Please try different keywords or check filenames.`, error_message: null }; } let retrievedFileContent = null; let statusMessage = ''; let errorMessage = null; let absoluteBestMatchPath = path_module.join(docsRootDir, bestMatch.file); // Path relative to docsRootDir try { if (!fs.existsSync(absoluteBestMatchPath)) { // throw new Error(`File ${bestMatch.file} (resolved to ${absoluteBestMatchPath}) not found.`); errorMessage = `File ${bestMatch.file} (resolved to ${absoluteBestMatchPath}) not found.`; statusMessage = `Error: File ${bestMatch.file} not found.`; return { retrieved_file_path: null, file_content: null, status_message: statusMessage, error_message: errorMessage }; } retrievedFileContent = fs.readFileSync(absoluteBestMatchPath, 'utf-8'); statusMessage = `Successfully retrieved documentation file: ${bestMatch.file} (relative to docs root)`; // console.log('Successfully read file:', bestMatch.file); } catch (error) { // console.error(`Error reading file ${bestMatch.file} (at ${absoluteBestMatchPath}):`, error.message); errorMessage = `Error reading file ${bestMatch.file}: ${error.message}`; statusMessage = `Found a potential match ${bestMatch.file}, but an error occurred while reading its content.`; } return { retrieved_file_path: errorMessage ? null : bestMatch.file, // Return path relative to docsRootDir file_content: retrievedFileContent, status_message: statusMessage, error_message: errorMessage }; }; const apiTool = { function: executeSarvamDocsFileRetrieval, definition: { type: 'function', function: { name: 'get_sarvam_documentation_file', description: 'Retrieves the full content of the single most relevant Sarvam AI markdown documentation file. Searches local documentation (e.g., in \'docs/api-ref\', \'docs/cookbook\') based on keywords, a topic, or a filename.', parameters: { type: 'object', properties: { search_term: { type: 'string', description: "Keywords, a topic description (e.g., 'how to use transliteration api', 'pdf parsing options'), or a partial/full filename (e.g., 'sarvam-parse.md', 'transliterate'). The tool will attempt to find the single most relevant documentation file." }, doc_area: { type: 'string', description: "Optional. Specify a documentation area to narrow the search. Common areas include: 'api-ref' (for API endpoint details), 'cookbook' (for usage guides and examples), 'docs-section' (for general conceptual documents like overviews). If 'search_term' is a clear filename, this might be omitted. If omitted and 'search_term' is keyword-based, common areas will be searched." } }, required: ['search_term'] } } } }; export { apiTool };