UNPKG

@chainlink/mcp-server

Version:
1,019 lines (1,015 loc) 58.3 kB
"use strict"; /** * @fileoverview Unified Chainlink Developer Assistant Tool * * OVERVIEW: * This is the main developer assistance tool for the Chainlink ecosystem. It provides * a single, intelligent interface that can answer any Chainlink-related question by * combining multiple data sources and using AI to generate comprehensive responses. * * KEY FEATURES: * - Smart chain detection: Automatically identifies which blockchain networks the user * is asking about and fetches focused configuration data for those specific chains * - Multi-source data: Combines fetched API data, static configuration files, and * documentation search to provide complete answers * - AI-powered responses: Uses LLM to generate natural language responses with * accurate technical details and working code examples * * ARCHITECTURE: * 1. Query Analysis: Extracts chain names from user queries without hardcoded lists * 2. Data Gathering: Fetches relevant data from multiple sources in parallel * 3. AI Generation: Provides all data to LLM for intelligent response generation * * EXAMPLE USAGE: * User asks: "How do I send tokens from Ethereum to Polygon?" * Tool automatically: * - Detects "Ethereum" and "Polygon" chains * - Fetches router addresses, chain selectors, and token data for both chains * - Finds lane information for Ethereum → Polygon transfers * - Generates working Solidity code with real addresses */ Object.defineProperty(exports, "__esModule", { value: true }); exports.chainlinkDeveloperAssistantHandler = exports.startChainlinkDeveloperAssistant = exports.calculateRelevance = exports.searchInCcipDataSources = exports.searchCcipDataSources = exports.getChainSpecificData = exports.extractChainNamesFromQuery = exports.searchVectorDatabase = exports.fetchAPIData = exports.loadCcipDataSources = exports.clearCcipDataSourcesCache = exports.setModuleVectorDb = void 0; const zod_1 = require("zod"); const fs_1 = require("fs"); const path_1 = require("path"); const logger_1 = require("../utils/logger"); const service_factory_1 = require("../services/service-factory"); const database_1 = require("../vectordb/database"); // ============================================================================ // GLOBAL STATE & TEST UTILITIES // ============================================================================ /** * Vector database instance for documentation search. * Initialized when the tool starts up, used to search through Chainlink docs. */ let moduleVectorDb = null; /** * Test utility: Allows tests to inject a mock vector database */ const setModuleVectorDb = (db) => { moduleVectorDb = db; }; exports.setModuleVectorDb = setModuleVectorDb; /** * Test utility: Clears the configuration cache between test runs */ const clearCcipDataSourcesCache = () => { ccipDataSourcesCache = {}; }; exports.clearCcipDataSourcesCache = clearCcipDataSourcesCache; // ============================================================================ // DATA SCHEMAS & VALIDATION // ============================================================================ /** * These schemas validate the structure of Chainlink configuration data. * They ensure data integrity and provide TypeScript types for better development experience. * TODO @dev: Expand schemas to include other Chainlink products (VRF, Data Feeds, Functions, Automation) */ /** * Schema for contract configuration (router, ARM proxy, etc.) * Contains the contract address and version information */ const ContractConfigSchema = zod_1.z.object({ address: zod_1.z.string(), version: zod_1.z.string(), }); /** * Schema for blockchain network configuration * Contains all the essential contract addresses and identifiers for a specific chain */ const ChainConfigSchema = zod_1.z.object({ feeTokens: zod_1.z.array(zod_1.z.string()), // Tokens accepted for CCIP fees chainSelector: zod_1.z.string(), // Unique identifier for this chain in CCIP router: ContractConfigSchema, // Main CCIP router contract armProxy: ContractConfigSchema, // ARM (Anti-fraud Risk Management) contract registryModule: ContractConfigSchema.optional(), // Registry for managing configurations tokenAdminRegistry: ContractConfigSchema.optional(), // Registry for token administrators tokenPoolFactory: ContractConfigSchema.optional(), // Factory for creating token pools }); /** * Schema for token configuration on a specific chain * Defines how a token behaves in CCIP transfers (rates, limits, etc.) */ const TokenConfigSchema = zod_1.z.object({ allowListEnabled: zod_1.z.boolean(), // Whether token transfers require allowlist approval decimals: zod_1.z.number(), // Token decimal places (e.g., 18 for most ERC20s) name: zod_1.z.string(), // Full token name (e.g., "Chainlink Token") poolAddress: zod_1.z.string().optional(), // Address of the token pool contract poolType: zod_1.z.string(), // Type of pool (lockRelease, burnMint, etc.) symbol: zod_1.z.string(), // Token symbol (e.g., "LINK") tokenAddress: zod_1.z.string(), // Address of the token contract }); /** * Schema for rate limiting configuration * Controls how fast tokens can be transferred to prevent abuse */ const RateLimiterConfigSchema = zod_1.z.object({ capacity: zod_1.z.string(), // Maximum tokens that can be transferred in a burst isEnabled: zod_1.z.boolean(), // Whether rate limiting is active rate: zod_1.z.string(), // Rate at which capacity refills (tokens per second) }); /** * Schema for CCIP lane configuration * A "lane" is a directional pathway between two blockchain networks * Example: Ethereum → Polygon is one lane, Polygon → Ethereum is another */ const LaneConfigSchema = zod_1.z.object({ offRamp: ContractConfigSchema, // Contract on destination chain that receives messages onRamp: ContractConfigSchema.extend({ // Contract on source chain that sends messages enforceOutOfOrder: zod_1.z.boolean().optional(), // Whether messages must be processed in order }), rmnPermeable: zod_1.z.boolean(), // Whether ARM can halt this lane for security supportedTokens: zod_1.z // Tokens that can be transferred through this lane .record(zod_1.z.object({ rateLimiterConfig: zod_1.z.object({ in: RateLimiterConfigSchema.optional(), // Rate limits for incoming transfers out: RateLimiterConfigSchema.optional(), // Rate limits for outgoing transfers }), })) .optional(), }); // ============================================================================ // CONSTANTS & CONFIGURATION // ============================================================================ /** * Product identifier for CCIP (Cross-Chain Interoperability Protocol) * TODO @dev: Add constants for other Chainlink products when expanding beyond CCIP */ const PRODUCT_CCIP = "ccip"; /** * Supported blockchain environments * - mainnet: Production networks with real value * - testnet: Test networks for development and testing */ const SUPPORTED_ENVIRONMENTS = ["mainnet", "testnet"]; /** * Regex pattern for normalizing chain names during matching * Removes hyphens, underscores, and spaces so "ethereum-mainnet", "ethereum_mainnet", * and "ethereum mainnet" all match when searching */ const CHAIN_NAME_NORMALIZER = /[-_\s]/g; /** * Remove separators from text for fuzzy matching * * This function removes hyphens, underscores, and spaces from text to enable * flexible matching between different naming conventions. For example: * - "avalanche-fuji-testnet" becomes "avalanchefujitestnet" * - "avalanche fuji" becomes "avalanchefuji" * - "ethereum_mainnet" becomes "ethereummainnet" * * This allows queries like "avalanche fuji" to match "avalanche-fuji-testnet". * * @param text - The text to normalize * @returns Text with separators removed */ const removeSeparatorsForMatching = (text) => { return text.replace(CHAIN_NAME_NORMALIZER, ""); }; /** * Complete CCIP configuration schema that combines all data types * This represents the structure of our unified configuration files */ const CombinedCcipConfigSchema = zod_1.z.object({ chains: zod_1.z.record(ChainConfigSchema), // All supported blockchain networks tokens: zod_1.z.record(zod_1.z.record(TokenConfigSchema)), // Tokens organized by symbol, then by chain lanes: zod_1.z.record(zod_1.z.record(LaneConfigSchema)), // Cross-chain lanes organized by source->destination metadata: zod_1.z.object({ // Information about this configuration file environment: zod_1.z.string(), // "mainnet" or "testnet" version: zod_1.z.string(), // Configuration version lastUpdated: zod_1.z.string(), // When this config was last updated source: zod_1.z.string(), // Where this config came from }), }); // ============================================================================ // CACHING SYSTEM // ============================================================================ /** * In-memory cache for configuration data to avoid repeated file reads * Structure: { product: { environment: config | null } } * * Why caching is important: * - Configuration files are large (thousands of chains/tokens/lanes) * - File I/O is expensive compared to memory access * - Same configurations are often requested multiple times per query * - null values are cached to avoid repeated failed file reads */ let ccipDataSourcesCache = {}; // ============================================================================ // DATA LOADING FUNCTIONS // ============================================================================ /** * Load combined CCIP configuration from JSON file * * This function loads the unified configuration files that contain all CCIP data * (chains, tokens, lanes) for a specific product and environment. * * @param product - The Chainlink product (currently only "ccip") * @param environment - The blockchain environment ("mainnet" or "testnet") * @returns Promise resolving to the configuration object, or null if not found */ const loadCcipDataSources = async (product, environment) => { // Check cache first if (ccipDataSourcesCache[product]?.[environment] !== undefined) { return ccipDataSourcesCache[product][environment]; } try { // Construct file path for combined configuration const filePath = (0, path_1.join)(__dirname, "..", "..", "data", "external", "documentation", "src", "config", "data", product, "v1_2_0", environment, "ccip-config.json"); logger_1.Logger.debug(`Attempting to load combined config from: ${filePath}`); if (!(0, fs_1.existsSync)(filePath)) { logger_1.Logger.debug(`File not found: ${filePath}`); return null; } const rawData = (0, fs_1.readFileSync)(filePath, "utf8"); const jsonData = JSON.parse(rawData); // Validate the combined configuration const validatedData = CombinedCcipConfigSchema.parse(jsonData); // Cache the validated data if (!ccipDataSourcesCache[product]) { ccipDataSourcesCache[product] = {}; } ccipDataSourcesCache[product][environment] = validatedData; logger_1.Logger.debug(`Loaded and validated ${product} combined config for ${environment}`); return validatedData; } catch (error) { logger_1.Logger.debug(`Failed to load ${product} combined config for ${environment}: ${error}`); // Cache null result to avoid repeated attempts if (!ccipDataSourcesCache[product]) { ccipDataSourcesCache[product] = {}; } ccipDataSourcesCache[product][environment] = null; return null; } }; exports.loadCcipDataSources = loadCcipDataSources; /** * API endpoint configuration for Chainlink products * Centralized configuration to make URL changes easier to manage */ const API_ENDPOINTS = { ccip: { baseUrl: "https://docs.chain.link/api/ccip/v1", chains: (environment) => `${API_ENDPOINTS.ccip.baseUrl}/chains?environment=${environment}&outputKey=chainId`, }, // TODO @dev: Add endpoints for other Chainlink products (VRF, Data Feeds, Functions, Automation) }; /** * Fetch API data for Chainlink products (fallback data source) * * This function calls Chainlink's APIs to get network information as a fallback * when static configuration files are insufficient. The API data is less comprehensive * than our static configs but may include newer networks not yet in the JSON files. * * USAGE PRIORITY: This is a FALLBACK data source. Primary sources are: * 1. Static JSON configuration files (most comprehensive) * 2. Vector database documentation search * 3. API data (this function) - only when static data is incomplete * * @param product - The Chainlink product to fetch data for * @param environment - The blockchain environment ("mainnet" or "testnet") * @returns Promise resolving to the API response data * @throws Error if the API request fails or product is not supported */ const fetchAPIData = async (product, environment) => { let apiUrl; switch (product.toLowerCase()) { case "ccip": apiUrl = API_ENDPOINTS.ccip.chains(environment); break; // TODO @dev: Add support for other Chainlink products (VRF, Data Feeds, Functions, Automation) default: throw new Error(`Product "${product}" is not currently supported for API data fetching`); } try { const response = await fetch(apiUrl); if (!response.ok) { throw new Error(`API request failed: ${response.status} ${response.statusText}`); } return await response.json(); } catch (error) { logger_1.Logger.log("error", `Failed to fetch API data: ${error}`); throw error; } }; exports.fetchAPIData = fetchAPIData; /** * Search vector database for documentation * * This function searches through indexed Chainlink documentation using semantic * similarity. It's particularly useful for finding code examples, explanations, * and conceptual information that isn't in the configuration data. * * @param query - The search query (natural language) * @returns Promise resolving to an array of relevant documentation snippets * @throws Error if vector database is not initialized or search fails */ const searchVectorDatabase = async (query) => { if (!moduleVectorDb) { throw new Error("Vector database not initialized"); } try { const results = await moduleVectorDb.searchSimilar(query, 10); return results; } catch (error) { logger_1.Logger.log("error", `Vector database search failed: ${error}`); throw error; } }; exports.searchVectorDatabase = searchVectorDatabase; // ============================================================================ // SMART QUERY ANALYSIS // ============================================================================ /** * Extract potential chain names from query using dynamic discovery * * This is a key optimization function that makes the tool much more efficient. * Instead of searching through all 6,000+ configuration entries, it identifies * which specific blockchain networks the user is asking about and focuses the * search on just those networks. * * HOW IT WORKS: * 1. Loads all available chain names from configuration files * 2. Breaks chain names into component parts (e.g., "avalanche", "fuji", "testnet") * 3. Matches these against the user's query using fuzzy matching * 4. Returns all potential matches (disambiguation handled by relevance scoring) * 5. Sorts by length to prioritize more specific matches * * EXAMPLE: * Query: "send tokens from Avalanche Fuji to BSC testnet" * Returns: ["avalanche-fuji-testnet", "bsc-testnet", "avalanche", "fuji"] * * @param query - The user's natural language query * @returns Promise resolving to array of potential chain names, most specific first */ const extractChainNamesFromQuery = async (query) => { const foundChains = []; const lowercaseQuery = query.toLowerCase(); // Get all available chain names from both environments const allChainNames = new Set(); const chainComponents = new Set(); for (const env of SUPPORTED_ENVIRONMENTS) { try { let config = ccipDataSourcesCache[PRODUCT_CCIP]?.[env]; if (config === undefined) { config = await (0, exports.loadCcipDataSources)(PRODUCT_CCIP, env); } if (config && config.chains) { Object.keys(config.chains).forEach((chainName) => { allChainNames.add(chainName); // Add component parts for matching chainName.split("-").forEach((part) => { if (part.length > 1) { chainComponents.add(part); } }); }); } } catch (error) { logger_1.Logger.log("warn", `Error processing chains for ${env} environment: ${error}`); } } const allChains = Array.from(allChainNames); const processedComponents = new Set(); // Find exact chain matches first for (const chainName of allChains) { const normalizedChain = removeSeparatorsForMatching(chainName.toLowerCase()); const normalizedQuery = removeSeparatorsForMatching(lowercaseQuery); if (normalizedQuery.includes(normalizedChain) || normalizedChain.includes(normalizedQuery)) { foundChains.push(chainName); } } // Find component matches and resolve ambiguous ones for (const component of chainComponents) { if (processedComponents.has(component) || !lowercaseQuery.includes(component)) { continue; } processedComponents.add(component); // Find all chains that contain this component const matchingChains = allChains.filter((chain) => chain.includes(component)); // Add matching chains (limit to avoid overwhelming results) const topMatches = matchingChains.slice(0, 5); foundChains.push(...topMatches); // Always add the component itself for broader matching foundChains.push(component); } // Remove duplicates and sort by length (longest first) to prioritize more specific matches const uniqueChains = Array.from(new Set(foundChains)); return uniqueChains.sort((a, b) => b.length - a.length); }; exports.extractChainNamesFromQuery = extractChainNamesFromQuery; /** * Get complete configuration data for specific chains * * Once we know which chains the user is asking about, this function retrieves * ALL relevant data for those specific chains from both mainnet and testnet * configurations. This includes: * - Chain configurations (router addresses, chain selectors, etc.) * - Token configurations for those chains * - Lane configurations (cross-chain pathways) involving those chains * * This focused approach means instead of returning 6,000+ results, we return * only the 50-100 results that are actually relevant to the user's query. * * @param chainNames - Array of chain names detected from the user's query * @returns Promise resolving to organized configuration data for the specified chains */ const getChainSpecificData = async (chainNames) => { const result = { chains: {}, tokens: {}, lanes: {}, metadata: [], }; for (const env of SUPPORTED_ENVIRONMENTS) { try { let config = ccipDataSourcesCache[PRODUCT_CCIP]?.[env]; if (config === undefined) { config = await (0, exports.loadCcipDataSources)(PRODUCT_CCIP, env); } if (config) { // Find exact and partial chain matches const matchingChains = Object.keys(config.chains).filter((chainKey) => { return chainNames.some((queryChain) => { const normalizedChainKey = removeSeparatorsForMatching(chainKey.toLowerCase()); const normalizedQueryChain = removeSeparatorsForMatching(queryChain.toLowerCase()); return (normalizedChainKey.includes(normalizedQueryChain) || normalizedQueryChain.includes(normalizedChainKey)); }); }); // Get chain data for (const chainKey of matchingChains) { if (!result.chains[chainKey]) { result.chains[chainKey] = {}; } result.chains[chainKey][env] = config.chains[chainKey]; } // Get token data for matching chains if (config.tokens) { for (const [tokenSymbol, tokenChains] of Object.entries(config.tokens)) { for (const chainKey of matchingChains) { if (tokenChains[chainKey]) { if (!result.tokens[tokenSymbol]) { result.tokens[tokenSymbol] = {}; } if (!result.tokens[tokenSymbol][chainKey]) { result.tokens[tokenSymbol][chainKey] = {}; } result.tokens[tokenSymbol][chainKey][env] = tokenChains[chainKey]; } } } } // Get lane data for matching chains if (config.lanes) { for (const chainKey of matchingChains) { if (config.lanes[chainKey]) { if (!result.lanes[chainKey]) { result.lanes[chainKey] = {}; } result.lanes[chainKey][env] = config.lanes[chainKey]; } } // Also check if any matching chains are destinations for (const [sourceChain, destinations] of Object.entries(config.lanes)) { for (const chainKey of matchingChains) { if (destinations[chainKey]) { if (!result.lanes[sourceChain]) { result.lanes[sourceChain] = {}; } if (!result.lanes[sourceChain][env]) { result.lanes[sourceChain][env] = {}; } result.lanes[sourceChain][env][chainKey] = destinations[chainKey]; } } } } // Add metadata about what was found result.metadata.push({ environment: env, matchingChains: matchingChains, totalChains: Object.keys(config.chains).length, totalTokens: config.tokens ? Object.keys(config.tokens).length : 0, totalLanes: config.lanes ? Object.keys(config.lanes).length : 0, }); } } catch (error) { logger_1.Logger.log("warn", `Error processing chain-specific data for ${env} environment: ${error}`); } } return result; }; exports.getChainSpecificData = getChainSpecificData; // ============================================================================ // MAIN SEARCH ORCHESTRATION // ============================================================================ /** * Search data sources based on context * * This is the main search orchestration function that implements our smart * search strategy: * * SMART PATH (when specific chains are detected): * 1. Extract chain names from the query * 2. Get focused data for just those chains * 3. Return high-relevance results (50-100 items instead of 6,000+) * * FALLBACK PATH (when no specific chains detected): * 1. Search all configuration data * 2. Score results by relevance * 3. Return top 50 results to avoid overwhelming the AI * * This approach solved our core problem: queries like "Hedera testnet router" * were finding the right data but it was buried at result #5461 out of 6000+. * Now Hedera-specific results come back as the top results with 100% relevance. * * @param query - The user's natural language query * @returns Promise resolving to array of relevant configuration data, sorted by relevance */ const searchCcipDataSources = async (query) => { // First, try to extract chain names from the query const chainNames = await (0, exports.extractChainNamesFromQuery)(query); if (chainNames.length > 0) { logger_1.Logger.debug(`Found potential chains in query: ${chainNames.join(", ")}`); // Get focused data for the specific chains const chainSpecificData = await (0, exports.getChainSpecificData)(chainNames); // Convert to the expected result format const results = []; // Add chain results for (const [chainKey, chainData] of Object.entries(chainSpecificData.chains)) { for (const [env, data] of Object.entries(chainData)) { const relevance = (0, exports.calculateRelevance)(chainKey, query, chainKey) + 50; // Boost for chain-specific search results.push({ environment: env, dataType: "chains", key: chainKey, data: data, relevance: relevance, }); } } // Add token results for (const [tokenSymbol, tokenData] of Object.entries(chainSpecificData.tokens)) { for (const [chainKey, chainTokens] of Object.entries(tokenData)) { for (const [env, data] of Object.entries(chainTokens)) { // Calculate relevance based on both token symbol and query const tokenRelevance = (0, exports.calculateRelevance)(tokenSymbol, query); const chainRelevance = (0, exports.calculateRelevance)(chainKey, query, chainKey); // Give extra boost for direct token name matches let tokenBoost = 100; // Base boost for token matches if (tokenRelevance > 0) { tokenBoost = 200; // Higher boost if token name actually matches the query } const combinedRelevance = Math.max(tokenRelevance, chainRelevance) + tokenBoost; results.push({ environment: env, dataType: "tokens", key: `${tokenSymbol}.${chainKey}`, data: data, relevance: combinedRelevance, }); } } } // Add lane results for (const [sourceChain, laneData] of Object.entries(chainSpecificData.lanes)) { for (const [env, envData] of Object.entries(laneData)) { for (const [destChain, data] of Object.entries(envData)) { // Calculate relevance for lane based on source and destination chains const sourceRelevance = (0, exports.calculateRelevance)(sourceChain, query, sourceChain); const destRelevance = (0, exports.calculateRelevance)(destChain, query, destChain); const laneRelevance = Math.max(sourceRelevance, destRelevance) + 75; // Boost for lane matches results.push({ environment: env, dataType: "lanes", key: `${sourceChain}->${destChain}`, data: data, relevance: laneRelevance, }); } } } if (results.length > 0) { logger_1.Logger.debug(`Found ${results.length} chain-specific results`); return results.sort((a, b) => (b.relevance || 0) - (a.relevance || 0)); } } // Fallback to original search if no specific chains found logger_1.Logger.debug("No specific chains found, falling back to general search"); const results = []; for (const env of SUPPORTED_ENVIRONMENTS) { try { let config = ccipDataSourcesCache[PRODUCT_CCIP]?.[env]; if (config === undefined) { config = await (0, exports.loadCcipDataSources)(PRODUCT_CCIP, env); } if (config) { // Search within chains const chainResults = (0, exports.searchInCcipDataSources)(config.chains, query, undefined, env, "chains"); results.push(...chainResults); // Search within tokens const tokenResults = (0, exports.searchInCcipDataSources)(config.tokens, query, undefined, env, "tokens"); results.push(...tokenResults); // Search within lanes const laneResults = (0, exports.searchInCcipDataSources)(config.lanes, query, undefined, env, "lanes"); results.push(...laneResults); } } catch (error) { logger_1.Logger.log("warn", `Failed to search ${PRODUCT_CCIP} configuration in ${env}: ${error}`); } } // Sort by relevance and limit results to prevent overwhelming the AI return results .sort((a, b) => (b.relevance || 0) - (a.relevance || 0)) .slice(0, 50); // Limit to top 50 results }; exports.searchCcipDataSources = searchCcipDataSources; // ============================================================================ // SEARCH UTILITIES // ============================================================================ /** * Search within data sources structure * * This is a recursive search function that traverses the configuration data * structure looking for matches. It handles the complex nested structure of * our configuration files (chains -> tokens -> lanes -> rate limiters, etc.) * * @param data - The data structure to search within * @param query - The search query * @param specificChain - Optional chain name to focus the search * @param environment - The environment this data comes from * @param dataType - The type of data being searched (chains, tokens, lanes) * @returns Array of matching results with relevance scores */ const searchInCcipDataSources = (data, query, specificChain, environment, dataType) => { const results = []; const lowercaseQuery = query.toLowerCase(); if (typeof data === "object" && data !== null) { for (const [key, value] of Object.entries(data)) { const lowercaseKey = key.toLowerCase(); // Check if this entry matches the search criteria let matches = false; if (specificChain) { const normalizedChain = specificChain .toLowerCase() .replace(CHAIN_NAME_NORMALIZER, ""); const normalizedKey = lowercaseKey.replace(CHAIN_NAME_NORMALIZER, ""); matches = normalizedKey.includes(normalizedChain) || normalizedChain.includes(normalizedKey); } else { // Normalize both query and key for better matching const normalizedQuery = lowercaseQuery.replace(CHAIN_NAME_NORMALIZER, ""); const normalizedKey = lowercaseKey.replace(CHAIN_NAME_NORMALIZER, ""); matches = lowercaseKey.includes(lowercaseQuery) || lowercaseQuery.includes(lowercaseKey) || normalizedKey.includes(normalizedQuery) || normalizedQuery.includes(normalizedKey); } if (matches) { results.push({ environment, dataType, key, data: value, relevance: (0, exports.calculateRelevance)(key, query, specificChain), }); } // Recursive search for nested objects if (typeof value === "object" && value !== null) { const nestedResults = (0, exports.searchInCcipDataSources)(value, query, specificChain, environment, `${dataType}.${key}`); results.push(...nestedResults); } } } return results; }; exports.searchInCcipDataSources = searchInCcipDataSources; /** * Calculate relevance score for search results * * This scoring system ensures the most relevant results appear first. * Higher scores mean more relevant results: * - Exact matches: 100+ points * - Partial matches: 25-75 points * - Chain-specific matches: Additional 65-200 points * * The normalization (removing hyphens, underscores, spaces) ensures * "ethereum-mainnet", "ethereum_mainnet", and "ethereum mainnet" all match. * * @param key - The configuration key being scored * @param query - The user's search query * @param specificChain - Optional specific chain name for bonus scoring * @returns Numeric relevance score (higher = more relevant) */ const calculateRelevance = (key, query, specificChain) => { let score = 0; const lowercaseKey = key.toLowerCase(); const lowercaseQuery = query.toLowerCase(); // Normalize for better matching (removes hyphens, underscores, spaces) const normalizedKey = lowercaseKey.replace(CHAIN_NAME_NORMALIZER, ""); const normalizedQuery = lowercaseQuery.replace(CHAIN_NAME_NORMALIZER, ""); // Exact match gets highest score if (lowercaseKey === lowercaseQuery) score += 100; else if (normalizedKey === normalizedQuery) score += 90; // Normalized exact match else if (lowercaseKey.includes(lowercaseQuery)) score += 50; else if (normalizedKey.includes(normalizedQuery)) score += 45; // Normalized partial match else if (lowercaseQuery.includes(lowercaseKey)) score += 30; else if (normalizedQuery.includes(normalizedKey)) score += 25; // Normalized reverse match // Additional check: see if any parts of the key are mentioned in the query // This is especially important for tokens like "CCIP-BnM" where "BnM" might be mentioned // Only apply this bonus if we haven't already found a direct match if (score === 0) { const keyParts = lowercaseKey.split(/[-_]/); for (const part of keyParts) { if (part.length > 2 && lowercaseQuery.includes(part)) { score += 40; // Good score for partial token name matches break; // Only give this bonus once } } } // Context-aware disambiguation for ambiguous chain names // Only apply when query contains ambiguous terms that could match multiple chains if (lowercaseQuery.includes("sepolia")) { const keyParts = lowercaseKey.split("-"); if (keyParts.includes("sepolia")) { // Score based on context words present in the query const contextWords = keyParts.filter((part) => part !== "sepolia" && part.length > 1); const contextMatches = contextWords.filter((word) => lowercaseQuery.includes(word)); // Boost score for chains that match query context score += contextMatches.length * 15; // Special boost for canonical ethereum sepolia when no other context if (keyParts.includes("ethereum") && keyParts.includes("testnet")) { if (contextMatches.length === 0) { score += 10; // Default choice for plain "sepolia" queries } } } } // Chain-specific matching if (specificChain) { const lowercaseChain = specificChain.toLowerCase(); const normalizedChain = lowercaseChain.replace(CHAIN_NAME_NORMALIZER, ""); if (lowercaseKey === lowercaseChain) { score += 200; // Extra boost for chains that are specifically mentioned in the query // This ensures that when a user asks about "hedera testnet", hedera-testnet // gets higher priority than other testnet chains const queryParts = lowercaseQuery.replace(/[^\w\s]/g, "").split(/\s+/); const chainParts = lowercaseChain.split(/[-_]/); let mentionedParts = 0; for (const chainPart of chainParts) { if (chainPart.length > 2 && queryParts.includes(chainPart)) { mentionedParts++; } } if (mentionedParts > 1) { score += 50; // Extra boost for multi-part chain matches (e.g., "hedera" + "testnet") } } else if (normalizedKey === normalizedChain) score += 180; // Normalized exact chain match else if (lowercaseKey.includes(lowercaseChain)) score += 75; else if (normalizedKey.includes(normalizedChain)) score += 65; // Normalized partial chain match } return score; }; exports.calculateRelevance = calculateRelevance; // ============================================================================ // TOOL INITIALIZATION & REGISTRATION // ============================================================================ /** * Initialize and register the unified Chainlink developer assistant tool * * This function sets up the tool and registers it with the MCP server. * It also initializes the vector database for documentation search. * * @param server - The MCP server instance to register the tool with */ const startChainlinkDeveloperAssistant = async (server) => { // Initialize vector database (defaults to read-only mode) try { moduleVectorDb = new database_1.VectorDatabase(); await moduleVectorDb.initialize(); logger_1.Logger.log("info", "Vector database initialized for developer assistant"); } catch (error) { logger_1.Logger.log("error", `Failed to initialize vector database: ${error}`); // Set moduleVectorDb to null so queries will continue without vector search moduleVectorDb = null; } server.tool("chainlink_developer_assistant", "Unified tool for all Chainlink developer queries. Supports fetched API data, reference configurations, and documentation search. Handles queries in natural language.", { query: zod_1.z.string().describe("Your question about Chainlink in natural language. Examples: 'How do I send a CCIP transaction from Ethereum to Polygon?', 'What is the router address for Ethereum mainnet?', 'Show me a code example for receiving CCIP messages', 'Get supported tokens for Sepolia testnet'" // TODO @dev: Add examples for other products when implemented ), }, exports.chainlinkDeveloperAssistantHandler); }; exports.startChainlinkDeveloperAssistant = startChainlinkDeveloperAssistant; // ============================================================================ // MAIN QUERY HANDLER // ============================================================================ /** * Handle unified Chainlink developer queries * * This is the main entry point for all user queries. It orchestrates the entire * process of answering a Chainlink developer question: * * PROCESS FLOW: * 1. Validate the input query * 2. Set up the AI service for response generation * 3. Fetch data from multiple sources in parallel: * - Fetched API data (network info from Chainlink APIs) * - CCIP configuration data (comprehensive static configs) * - Documentation search (guides, examples, concepts) * 4. Organize and prioritize the results * 5. Generate a comprehensive AI response using all available data * * The AI receives a detailed system prompt that explains how to use each data * source appropriately, ensuring accurate and helpful responses. */ // Input validation schema const ChainlinkDeveloperAssistantParamsSchema = zod_1.z.object({ query: zod_1.z.string().min(1, "Query cannot be empty"), }); const chainlinkDeveloperAssistantHandler = async (params, _extra) => { // Validate input parameters try { ChainlinkDeveloperAssistantParamsSchema.parse(params); } catch (error) { const validationError = `Invalid parameters: ${error}`; logger_1.Logger.log("error", validationError); return { content: [{ type: "text", text: validationError }], isError: true, }; } // Create response generator LLM service let responseGeneratorLLM; try { responseGeneratorLLM = await service_factory_1.AIServiceFactory.createService(); } catch (error) { const configErrorMsg = `Response generator LLM configuration error: ${error}`; logger_1.Logger.log("error", configErrorMsg); return { content: [{ type: "text", text: configErrorMsg }], isError: true, }; } const { query } = params; logger_1.Logger.log("info", `Processing developer query: ${query.substring(0, 100)}...`); try { let combinedResults = { fetchedAPIData: null, ccipDataSources: [], documentationData: [], }; // Query all available data sources const promises = []; // Fetch API data as fallback - potential use cases: // 1. User asks about very new chains not yet in JSON files // 2. JSON files are corrupted/missing for some environment // 3. User specifically mentions needing "latest" or "most current" data // This is now de-prioritized compared to JSON files and vector database promises.push((0, exports.fetchAPIData)("ccip", "mainnet") .then((data) => ({ type: "api-mainnet", data })) .catch((error) => ({ type: "api-mainnet", error: error.message }))); promises.push((0, exports.fetchAPIData)("ccip", "testnet") .then((data) => ({ type: "api-testnet", data })) .catch((error) => ({ type: "api-testnet", error: error.message }))); // Always search data sources promises.push((0, exports.searchCcipDataSources)(query) .then((data) => ({ type: "ccipDataSources", data })) .catch((error) => ({ type: "ccipDataSources", error: error.message, }))); // Always search documentation if vector database is available if (moduleVectorDb) { promises.push((0, exports.searchVectorDatabase)(query) .then((data) => ({ type: "documentation", data })) .catch((error) => ({ type: "documentation", error: error.message }))); } // Wait for all queries to complete const results = await Promise.all(promises); // Organize results for (const result of results) { if (result.error) { logger_1.Logger.log("warn", `${result.type} query failed: ${result.error}`); continue; } switch (result.type) { case "api-mainnet": combinedResults.fetchedAPIData = { mainnet: result.data, ...(combinedResults.fetchedAPIData || {}), }; break; case "api-testnet": combinedResults.fetchedAPIData = { testnet: result.data, ...(combinedResults.fetchedAPIData || {}), }; break; case "ccipDataSources": // Sort results by relevance (highest first) before storing combinedResults.ccipDataSources = result.data.sort((a, b) => (b.relevance || 0) - (a.relevance || 0)); break; case "documentation": combinedResults.documentationData = result.data; break; } } // Check data source availability for user communication const dataSourceStatus = { fetchedAPI: { mainnet: !!combinedResults.fetchedAPIData?.mainnet, testnet: !!combinedResults.fetchedAPIData?.testnet, }, ccipDataSources: combinedResults.ccipDataSources.length > 0, documentation: false, }; // Check vector database availability if (moduleVectorDb) { try { const dbStats = await moduleVectorDb.getStats(); dataSourceStatus.documentation = dbStats.tableExists; logger_1.Logger.log("info", `Vector database available: ${dataSourceStatus.documentation}`); } catch (error) { logger_1.Logger.log("warn", `Vector database check failed: ${error}`); dataSourceStatus.documentation = false; } } // Create status message for user if any data sources are unavailable const unavailableSources = []; if (!dataSourceStatus.fetchedAPI.mainnet && !dataSourceStatus.fetchedAPI.testnet) { unavailableSources.push("Fetched API data (network connectivity issues)"); } if (!dataSourceStatus.ccipDataSources) { unavailableSources.push("CCIP data sources configuration data"); } if (!dataSourceStatus.documentation) { unavailableSources.push("Documentation search (vector database not available)"); } const statusMessage = unavailableSources.length > 0 ? `\n\n⚠️ **Data Source Status**: The following data sources are currently unavailable: ${unavailableSources.join(", ")}. Responses may be limited to available data sources.` : ""; // Generate comprehensive AI prompt const systemPrompt = `<purpose>You are a Chainlink developer assistant with access to comprehensive Chainlink ecosystem data sources.</purpose> <available_data_sources> <source name="CCIP Data Sources" priority="PRIMARY">Official Chainlink JSON configuration files with chains, tokens, and CCIP lanes information - THIS IS YOUR PRIMARY SOURCE FOR ALL ADDRESSES AND SELECTORS</source> <source name="Documentation Database" priority="PRIMARY">Vector search across Chainlink documentation and GitHub repositories for concepts, guides, and code examples</source> <source name="Fetched API Data" priority="FALLBACK">Network configurations from Chainlink's API - use only when static JSON data is insufficient or missing</source> </available_data_sources> <ccip_concepts> <concept name="Lanes">CCIP lanes are directional pathways between blockchain networks that enable cross-chain token transfers and message passing. Each lane has: - OnRamp contract (source chain) for sending messages/tokens - OffRamp contract (destination chain) for receiving messages/tokens - Supported tokens with rate limiting configurations - When users ask about "sending tokens from X to Y" or "transferring from A to B", they need a lane from source to destination</concept> <concept name="Cross-Chain Operations">When users ask about sending, transferring, bridging, or moving tokens/messages between chains, they are asking about cross-chain operations that require: - Source chain configuration (router, supported tokens) - Destination chain configuration (router, supported tokens) - A lane connecting source to destination - Token support on both chains and in the lane</concept> <concept name="Chain Resolution">When users mention ambiguous chain names (e.g., "Sepolia" which could refer to multiple testnets), the tool uses intelligent resolution to determine the most likely intended chains based on context. For example, "Sepolia" alone typically refers to ethereum-testnet-sepolia, while "Arbitrum Sepolia" would refer to ethereum-testnet-sepolia-arbitrum-1.</concept> </ccip_concepts> <configuration_rules> <rule priority="critical">For ANY configuration data (router addresses, chain selectors, contract addresses), PRIORITIZE CCIP Data Sources (JSON files), then use Fetched API Data only as fallback</rule> <rule priority="critical">NEVER use addresses or selectors from Documentation Data or your training data</rule> <rule priority="important">If configuration data is not available in CCIP Data Sources or Fetched API data, explicitly state this limitation</rule> <rule priority="important">When providing code examples, use ONLY the addresses and selectors from the provided CCIP Data Sources or Fetched API data</rule> <rule priority="important">For cross-chain queries, check lane availability between source and destination chains</rule> </configuration_rules> <guidelines> <guideline>Use provided data to answer questions accurately and comprehensively</guideline> <guideline>If data is missing or unclear, explain what's available and suggest alternatives</guideline> <guideline>Include relevant code examples when requested</guideline> <guideline>For cross-chain operations, provide specific addresses and configurations when available</guideline> <guideline>Extract context from the user's query naturally - understand environments (mainnet/testnet), chains, and requirements</guideline> <guideline>When users ask about cross-cha