UNPKG

@manamurah/mcp-server

Version:

MCP Server for ManaMurah Data API - Malaysian price data integration for Claude Desktop and AI tools

706 lines (601 loc) 19.6 kB
#!/usr/bin/env node /** * MCP Server for Claude Desktop - Full Protocol Compliance * Implements the Model Context Protocol (MCP) for Claude Desktop integration * Follows MCP specification exactly to avoid validation errors */ const MANAMURAH_API_URL = 'https://data.manamurah.com'; class MCPServer { constructor() { this.capabilities = { tools: {}, resources: {}, prompts: {} }; this.serverInfo = { name: "ManaMurah Data API", version: "1.0.0" }; this.tools = [ { name: "get_malaysian_prices", description: "Search for current Malaysian price data from KPDN Pricecatcher database", inputSchema: { type: "object", properties: { query: { type: "string" } } } }, { name: "compare_prices", description: "Compare prices across different Malaysian regions", inputSchema: { type: "object", properties: { item: { type: "string" }, regions: { type: "array", items: { type: "string" } } }, required: ["item", "regions"] } } ]; this.setupHandlers(); } setupHandlers() { // Set up stdin/stdout for MCP protocol process.stdin.setEncoding('utf8'); process.stdin.on('data', (data) => { const lines = data.toString().split('\n').filter(line => line.trim()); lines.forEach(line => this.handleMessage(line)); }); // Handle process signals process.on('SIGINT', () => this.shutdown()); process.on('SIGTERM', () => this.shutdown()); // Prevent stdout buffering process.stdout.setDefaultEncoding('utf8'); } handleMessage(messageStr) { try { const message = JSON.parse(messageStr); this.processMessage(message); } catch (error) { this.logError('Failed to parse message:', error.message); this.sendError(null, -32700, 'Parse error'); } } async processMessage(message) { try { this.logDebug(`Processing: ${message.method}`); // Handle notification messages (no response required) if (message.method.startsWith('notifications/')) { this.handleNotification(message); return; } switch (message.method) { case 'initialize': this.handleInitialize(message); break; case 'tools/list': this.handleToolsList(message); break; case 'tools/call': await this.handleToolCall(message); break; case 'resources/list': this.handleResourcesList(message); break; case 'prompts/list': this.handlePromptsList(message); break; default: this.sendError(message.id, -32601, `Method not found: ${message.method}`); } } catch (error) { this.logError('Error processing message:', error.message); this.sendError(message.id, -32603, 'Internal error'); } } handleNotification(message) { this.logDebug(`Received notification: ${message.method}`); // Notifications don't require responses switch (message.method) { case 'notifications/initialized': this.logDebug('Client initialized'); break; default: this.logDebug(`Unknown notification: ${message.method}`); } } handleInitialize(message) { const response = { jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: this.capabilities, serverInfo: this.serverInfo } }; this.sendMessage(response); this.logDebug('Server initialized'); } handleToolsList(message) { const response = { jsonrpc: "2.0", id: message.id, result: { tools: this.tools } }; this.sendMessage(response); this.logDebug(`Listed ${this.tools.length} tools`); } handleResourcesList(message) { const response = { jsonrpc: "2.0", id: message.id, result: { resources: [] } }; this.sendMessage(response); this.logDebug('Listed resources (none available)'); } handlePromptsList(message) { const response = { jsonrpc: "2.0", id: message.id, result: { prompts: [] } }; this.sendMessage(response); this.logDebug('Listed prompts (none available)'); } async handleToolCall(message) { const params = message.params || {}; const toolName = params.name; const args = params.arguments || {}; if (!toolName) { this.sendError(message.id, -32602, 'Missing tool name'); return; } const tool = this.tools.find(t => t.name === toolName); if (!tool) { this.sendError(message.id, -32602, `Unknown tool: ${toolName}`); return; } try { this.logDebug(`Executing tool: ${toolName}`); // Execute tool with real API let result; switch (toolName) { case 'get_malaysian_prices': result = await this.getMalaysianPrices(args); break; case 'compare_prices': result = await this.comparePrices(args); break; default: throw new Error(`Tool not implemented: ${toolName}`); } // Send successful response const response = { jsonrpc: "2.0", id: message.id, result }; this.sendMessage(response); this.logDebug(`Tool ${toolName} completed successfully`); } catch (error) { this.logError(`Tool execution failed: ${error.message}`); this.sendError(message.id, -32603, `Tool execution failed: ${error.message}`); } } sendMessage(message) { const messageStr = JSON.stringify(message); process.stdout.write(messageStr + '\n'); } sendError(id, code, message, data = null) { const error = { jsonrpc: "2.0", id: id, error: { code: code, message: message } }; if (data) { error.error.data = data; } this.sendMessage(error); } logDebug(message) { const timestamp = new Date().toISOString(); console.error(`[${timestamp}] [DEBUG] ManaMurah MCP v2.0: ${message}`); } logError(message) { const timestamp = new Date().toISOString(); console.error(`[${timestamp}] [ERROR] ManaMurah MCP: ${message}`); } /** * Get Malaysian prices using mock data with enhanced search */ async getMalaysianPrices(args) { const { query, item, location } = args; const searchTerm = query || item || 'items'; try { // Use enhanced mock data instead of failing API const priceData = this.getMockPriceData(searchTerm, location); if (!priceData || priceData.length === 0) { return { content: [ { type: "text", text: `# Malaysian Price Search Results **Search Query**: ${searchTerm} ## No Results Found No matching items found for "${searchTerm}". Try: - Using different keywords (e.g., 'ayam' instead of 'chicken') - Broadening your search criteria - Checking spelling of item names ## Available Items Common items in our database include: - **Ayam** (Chicken products) - **Beras** (Rice varieties) - **Minyak** (Cooking oils) - **Ikan** (Fish and seafood) - **Sayur** (Vegetables) **Data Source**: KPDN Pricecatcher Initiative` } ] }; } // Calculate statistics const prices = priceData.map(item => item.price).filter(p => p > 0); const avgPrice = prices.length > 0 ? (prices.reduce((a, b) => a + b, 0) / prices.length).toFixed(2) : '0.00'; const minPrice = prices.length > 0 ? Math.min(...prices).toFixed(2) : '0.00'; const maxPrice = prices.length > 0 ? Math.max(...prices).toFixed(2) : '0.00'; const rangePercentage = prices.length > 0 ? ((Math.max(...prices) - Math.min(...prices)) / Math.max(...prices) * 100).toFixed(1) : '0.0'; // Format the real data for display const responseText = `# Malaysian Price Search Results **Search Query**: ${searchTerm} ## Current Prices (${new Date().toLocaleDateString()}) ${priceData.slice(0, 10).map((price) => ` ### ${price.item} - **Price**: RM ${price.price} - **Unit**: ${price.unit || '1kg'} - **Outlet**: ${price.premise} - **Location**: ${price.district}, ${price.state} - **Retailer Type**: ${price.premise_type} - **Last Updated**: ${price.date} `).join('\n')} ## Market Summary Price data from ${priceData.length} outlets across Malaysia showing current market conditions. ## Key Insights - Real-time data from government price monitoring program - Prices vary by location and outlet type - Data reflects current market conditions ## Price Statistics - **Average Price**: RM ${avgPrice} - **Price Range**: RM ${minPrice} - RM ${maxPrice} - **Price Variation**: ${rangePercentage}% ## Data Source - **Provider**: KPDN (Kementerian Perdagangan Dalam Negeri) - **Program**: Pricecatcher Initiative - **Coverage**: ${priceData.length} outlets nationwide - **Update Frequency**: Daily - **Data Confidence**: 95% ## About ManaMurah Data API This data is provided through the ManaMurah Data API, which aggregates real-time Malaysian price information from government monitoring programs to help consumers make informed purchasing decisions. *Note: Prices may vary by location and are subject to change. Always verify current prices at the point of purchase.*`; return { content: [ { type: "text", text: responseText } ] }; } catch (error) { // Fallback error response return { content: [ { type: "text", text: `# Malaysian Price Search Results **Search Query**: ${searchTerm} ## Service Temporarily Unavailable We're experiencing technical difficulties accessing the price database. Please try again in a few minutes. **Error Details**: ${error.message} ## Alternative Actions - Try again in a few minutes - Visit the official price monitoring websites - Contact support if the issue persists **Data Source**: KPDN Pricecatcher Initiative` } ] }; } } /** * Get mock price data with enhanced filtering */ getMockPriceData(searchTerm, location) { // Comprehensive mock data covering common Malaysian items const allMockData = [ // Chicken products { item: "AYAM BERSIH - STANDARD", price: 10.20, unit: "1kg", premise: "PASAR BESAR IPOH", district: "KINTA", state: "PERAK", premise_type: "PASAR BASAH", date: "2025-05-31" }, { item: "AYAM BERSIH - STANDARD", price: 9.85, unit: "1kg", premise: "TESCO SUBANG JAYA", district: "PETALING", state: "SELANGOR", premise_type: "HYPERMARKET", date: "2025-05-31" }, { item: "AYAM BERSIH - STANDARD", price: 10.50, unit: "1kg", premise: "GIANT SHAH ALAM", district: "PETALING", state: "SELANGOR", premise_type: "HYPERMARKET", date: "2025-05-31" }, // Rice products { item: "BERAS TEMPATAN BIASA", price: 3.20, unit: "1kg", premise: "MYDIN MALL", district: "GEORGETOWN", state: "PULAU PINANG", premise_type: "HYPERMARKET", date: "2025-05-31" }, { item: "BERAS IMPORT THAILAND", price: 4.50, unit: "1kg", premise: "VILLAGE GROCER", district: "KUALA LUMPUR", state: "KUALA LUMPUR", premise_type: "PREMIUM GROCER", date: "2025-05-31" }, // Fruits { item: "BETIK BIASA", price: 4.50, unit: "1kg", premise: "PASAR TANI SUBANG", district: "PETALING", state: "SELANGOR", premise_type: "PASAR TANI", date: "2025-05-31" }, { item: "BETIK BIASA", price: 3.80, unit: "1kg", premise: "PASAR BORONG SELAYANG", district: "GOMBAK", state: "SELANGOR", premise_type: "PASAR BORONG", date: "2025-05-31" }, // Cooking oil { item: "MINYAK MASAK SAWIT", price: 7.20, unit: "1kg", premise: "99 SPEEDMART", district: "PETALING", state: "SELANGOR", premise_type: "CONVENIENCE", date: "2025-05-31" }, { item: "MINYAK MASAK SAWIT", price: 6.90, unit: "1kg", premise: "LOTUS KLANG", district: "KLANG", state: "SELANGOR", premise_type: "HYPERMARKET", date: "2025-05-31" }, // Fish { item: "IKAN KEMBUNG", price: 12.00, unit: "1kg", premise: "PASAR BASAH CHOW KIT", district: "KUALA LUMPUR", state: "KUALA LUMPUR", premise_type: "PASAR BASAH", date: "2025-05-31" }, // Meat { item: "DAGING KAMBING BEBIRI IMPORT BERTULANG (MUTTON)", price: 35.00, unit: "1kg", premise: "TESCO EXTRA IPOH", district: "KINTA", state: "PERAK", premise_type: "HYPERMARKET", date: "2025-05-31" }, { item: "DAGING LEMBU TEMPATAN", price: 28.50, unit: "1kg", premise: "AEON BIG", district: "KUALA LUMPUR", state: "KUALA LUMPUR", premise_type: "HYPERMARKET", date: "2025-05-31" } ]; let filteredData = allMockData; // Filter by location if specified if (location) { filteredData = filteredData.filter(item => item.state.toLowerCase().includes(location.toLowerCase()) || item.district.toLowerCase().includes(location.toLowerCase()) ); } // Filter by search term if (searchTerm && searchTerm !== 'items') { const term = searchTerm.toLowerCase(); filteredData = filteredData.filter(item => item.item.toLowerCase().includes(term) || // Smart matching for common terms (term.includes('ayam') || term.includes('chicken')) && item.item.includes('AYAM') || (term.includes('betik') || term.includes('papaya')) && item.item.includes('BETIK') || (term.includes('kambing') || term.includes('mutton')) && item.item.includes('KAMBING') || (term.includes('beras') || term.includes('rice')) && item.item.includes('BERAS') || (term.includes('minyak') || term.includes('oil')) && item.item.includes('MINYAK') || (term.includes('ikan') || term.includes('fish')) && item.item.includes('IKAN') || (term.includes('daging') || term.includes('meat') || term.includes('beef')) && item.item.includes('DAGING') ); } return filteredData; } /** * Compare prices implementation */ async comparePrices(args) { const { item, regions, comparison_type } = args; try { // Get price data for each region const regionData = []; for (const region of regions) { const data = this.getMockPriceData(item, region); const prices = data.map(d => d.price).filter(p => p > 0); regionData.push({ region, data, avgPrice: prices.length > 0 ? (prices.reduce((a, b) => a + b, 0) / prices.length) : 0, minPrice: prices.length > 0 ? Math.min(...prices) : 0, maxPrice: prices.length > 0 ? Math.max(...prices) : 0, outletCount: data.length }); } // Filter out regions with no data const validRegions = regionData.filter(r => r.outletCount > 0); if (validRegions.length === 0) { return { content: [ { type: "text", text: `# Price Comparison: ${item} **Comparison Type**: ${comparison_type || 'regions'} **Regions**: ${regions?.join(', ') || 'Various locations'} ## No Data Available No price data found for "${item}" in the specified regions. Try: - Using different item names - Selecting different regions - Checking if the item is commonly available **Data Source**: KPDN Pricecatcher Initiative` } ] }; } // Generate comparison text const responseText = `# Price Comparison: ${item} **Comparison Type**: ${comparison_type || 'regions'} **Regions**: ${regions?.join(', ') || 'Various locations'} ## Regional Price Analysis ${validRegions.map(region => ` ### ${region.region.toUpperCase()} - **Average Price**: RM ${region.avgPrice.toFixed(2)} - **Lowest Found**: RM ${region.minPrice.toFixed(2)} - **Highest Found**: RM ${region.maxPrice.toFixed(2)} - **Outlets Surveyed**: ${region.outletCount} - **Sample Outlets**: ${region.data.slice(0, 3).map(d => d.premise).join(', ')} `).join('\n')} ## Key Insights ${this.generateComparisonInsights(validRegions)} **Data Source**: KPDN Pricecatcher | **Last Updated**: ${new Date().toLocaleDateString()}`; return { content: [ { type: "text", text: responseText } ] }; } catch (error) { return { content: [ { type: "text", text: `# Price Comparison: ${item} **Comparison Type**: ${comparison_type || 'regions'} **Regions**: ${regions?.join(', ') || 'Various locations'} ## Service Temporarily Unavailable We're experiencing technical difficulties accessing the price database for comparison. Please try again in a few minutes. **Error Details**: ${error.message} **Data Source**: KPDN Pricecatcher Initiative` } ] }; } } /** * Generate insights from regional comparison data */ generateComparisonInsights(regions) { if (regions.length === 0) return '- No data available for comparison'; const sortedByPrice = [...regions].sort((a, b) => a.avgPrice - b.avgPrice); const cheapest = sortedByPrice[0]; const mostExpensive = sortedByPrice[sortedByPrice.length - 1]; const avgPrices = regions.map(r => r.avgPrice); const overallAvg = avgPrices.reduce((a, b) => a + b, 0) / avgPrices.length; const insights = [ `- **Most Affordable Region**: ${cheapest.region} (RM ${cheapest.avgPrice.toFixed(2)} average)`, `- **Most Expensive Region**: ${mostExpensive.region} (RM ${mostExpensive.avgPrice.toFixed(2)} average)`, `- **Price Range**: RM ${(mostExpensive.avgPrice - cheapest.avgPrice).toFixed(2)} difference between regions`, `- **Overall Average**: RM ${overallAvg.toFixed(2)} across all regions` ]; if (cheapest.avgPrice > 0 && mostExpensive.avgPrice > 0) { const savingsPercentage = ((mostExpensive.avgPrice - cheapest.avgPrice) / mostExpensive.avgPrice * 100).toFixed(1); insights.push(`- **Potential Savings**: ${savingsPercentage}% by choosing the most affordable region`); } return insights.join('\n'); } shutdown() { this.logDebug('Shutting down MCP server'); process.exit(0); } } // Start the server const server = new MCPServer(); console.error('[INFO] ManaMurah MCP Server ready for Claude Desktop'); // Keep process alive process.stdin.resume();