Skip to main content
Glama
index.ts55.9 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js"; import { join, dirname } from "path"; import { homedir } from "os"; import { readFileSync, statSync, existsSync } from "fs"; import { fileURLToPath } from "url"; import { execSync } from "child_process"; import initSqlJs from "sql.js"; import type { SearchRankingsParams, HistoricalRankingsParams, AppKeywordsParams, KeywordTrendsParams, CompareRankingsParams, AppRatingsParams, RankingResult, HistoricalRankingResult, TrendAnalysis, KeywordCompetitorsParams, CompetitorResult, KeywordRecommendationsParams, KeywordRecommendation, CompetitiveLandscapeParams, CompetitiveLandscape, KeywordOpportunityParams, KeywordOpportunity, RankingAnomalyParams, RankingAnomaly, RankingPredictionParams, RankingPrediction, LowCompetitionParams, LowCompetitionKeyword, } from "./types.js"; // Database path const DB_PATH = join( homedir(), "Library/Containers/matteospada.it.ASO/Data/Library/Application Support/Astro/Model.sqlite" ); // Core Data epoch offset (seconds between Unix epoch and Core Data epoch) const CORE_DATA_EPOCH_OFFSET = 978307200; // Convert Core Data timestamp to JavaScript Date function convertCoreDataDate(timestamp: number | null): string | null { if (timestamp === null) return null; const unixTimestamp = timestamp + CORE_DATA_EPOCH_OFFSET; return new Date(unixTimestamp * 1000).toISOString(); } // Calculate standard deviation function calculateStdDev(values: number[]): number { const mean = values.reduce((a, b) => a + b, 0) / values.length; const squaredDiffs = values.map(v => Math.pow(v - mean, 2)); const avgSquaredDiff = squaredDiffs.reduce((a, b) => a + b, 0) / values.length; return Math.sqrt(avgSquaredDiff); } // Parse competitor JSON data from ZBINARYDATA function parseCompetitorData(rawData: any, keyword: string, limit: number = 10): Array<{appName: string, appId: string, ranking: number, category?: string}> { try { // Handle different data types let jsonString: string; if (typeof rawData === 'string') { jsonString = rawData; } else if (rawData instanceof Buffer) { jsonString = rawData.toString('utf8'); } else if (rawData instanceof Uint8Array) { jsonString = new TextDecoder('utf8').decode(rawData); } else { return []; } // Clean up the JSON string jsonString = jsonString.trim(); if (jsonString.startsWith('\uFEFF')) { jsonString = jsonString.substring(1); // Remove BOM } const jsonData = JSON.parse(jsonString); if (jsonData.apps && jsonData.apps.data) { return jsonData.apps.data.slice(0, limit).map((app: any, index: number) => ({ appName: app.artistName || app.attributes?.name || 'Unknown', appId: app.id || app.attributes?.platformAttributes?.ios?.bundleId || 'unknown', ranking: index + 1, category: app.attributes?.genreDisplayName || app.genreDisplayName || undefined, })); } return []; } catch (e) { console.error(`Failed to parse competitor data for keyword "${keyword}":`, e); return []; } } // Get package version const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')); const VERSION = packageJson.version; // Initialize MCP server const server = new Server( { name: "astro-mcp-server", version: VERSION, }, { capabilities: { tools: {}, }, } ); // Database connection and file monitoring let SQL: any; let db: any; let lastModTime: number = 0; let lastWalModTime: number = 0; async function initializeSqlJs() { SQL = await initSqlJs(); console.error("SQL.js initialized"); } function loadDatabase() { try { // Check if database file or WAL file has changed const stats = statSync(DB_PATH); const currentModTime = stats.mtimeMs; // Also check WAL file if it exists (for WAL mode databases) const walPath = DB_PATH + '-wal'; let currentWalModTime = 0; if (existsSync(walPath)) { const walStats = statSync(walPath); currentWalModTime = walStats.mtimeMs; } // Reload if main file or WAL file has changed, or it's the first load if (currentModTime !== lastModTime || currentWalModTime !== lastWalModTime) { console.error(`Loading database (DB modified: ${new Date(stats.mtime).toISOString()}, WAL exists: ${existsSync(walPath)})`); // If WAL file exists, force a checkpoint to merge changes if (existsSync(walPath)) { try { // Force a checkpoint to merge WAL changes into main database execSync(`sqlite3 "${DB_PATH}" "PRAGMA wal_checkpoint(TRUNCATE);"`, { stdio: 'pipe' }); console.error("Forced WAL checkpoint to merge recent changes"); } catch (checkpointError) { console.error("Warning: Could not force WAL checkpoint. Recent changes might not be visible."); } } // Now read the database file const fileBuffer = readFileSync(DB_PATH); db = new SQL.Database(fileBuffer); lastModTime = currentModTime; lastWalModTime = currentWalModTime; // Verify we can query it const result = db.exec("SELECT COUNT(*) as count FROM ZAPPLICATION"); if (result.length > 0) { console.error(`Database loaded: ${result[0].values[0][0]} applications found`); } } else { console.error(`Database unchanged (last modified: ${new Date(stats.mtime).toISOString()})`); } } catch (error: any) { console.error("Failed to load Astro database:", error.message); process.exit(1); } } // Helper function to ensure database is fresh before queries function ensureFreshDatabase() { loadDatabase(); } // Helper function to convert SQL.js results to array of objects function sqlResultToObjects(result: any): any[] { if (!result || result.length === 0) return []; const columns = result[0].columns; const values = result[0].values; return values.map((row: any[]) => { const obj: any = {}; columns.forEach((col: string, i: number) => obj[col] = row[i]); return obj; }); } // Tool: search_rankings async function searchRankings(params: SearchRankingsParams): Promise<RankingResult[]> { ensureFreshDatabase(); const { keyword, store, appName, appId } = params; let query = ` SELECT a.ZNAME as app_name, k.ZTEXT as keyword, k.ZCURRENTRANKING as current_ranking, k.ZPREVIOUSRANKING as previous_ranking, k.ZDIFFICULTY as difficulty, k.ZPOPULARITY as popularity, k.ZSTORE as store, k.ZLASTUPDATE as last_update FROM ZKEYWORD k JOIN ZAPPLICATION a ON k.ZAPPLICATION = a.Z_PK WHERE LOWER(k.ZTEXT) LIKE LOWER('%${keyword}%') `; if (store) { query += ` AND LOWER(k.ZSTORE) = LOWER('${store}')`; } if (appName) { query += ` AND LOWER(a.ZNAME) LIKE LOWER('%${appName}%')`; } else if (appId) { query += ` AND LOWER(a.ZAPPID) = LOWER('${appId}')`; } query += ` ORDER BY k.ZSTORE, k.ZCURRENTRANKING ASC NULLS LAST`; const result = db.exec(query); const rows = sqlResultToObjects(result); return rows.map((row: any) => ({ app: row.app_name, keyword: row.keyword, currentRanking: row.current_ranking, previousRanking: row.previous_ranking, difficulty: row.difficulty, popularity: row.popularity, store: row.store, lastUpdate: convertCoreDataDate(row.last_update), })); } // Tool: get_historical_rankings async function getHistoricalRankings(params: HistoricalRankingsParams): Promise<HistoricalRankingResult[]> { ensureFreshDatabase(); const { keyword, appName, appId, daysBack = 30, store } = params; const dateLimit = Date.now() / 1000 - CORE_DATA_EPOCH_OFFSET - (daysBack * 24 * 60 * 60); let query = ` SELECT a.ZNAME as app_name, k.ZTEXT as keyword, k.ZSTORE as store, d.ZRANKING as ranking, d.ZDATE as date FROM ZDATAPOINT d JOIN ZKEYWORD k ON d.ZKEYWORD = k.Z_PK JOIN ZAPPLICATION a ON k.ZAPPLICATION = a.Z_PK WHERE LOWER(k.ZTEXT) LIKE LOWER('%${keyword}%') AND d.ZDATE > ${dateLimit} `; if (store) { query += ` AND LOWER(k.ZSTORE) = LOWER('${store}')`; } if (appName) { query += ` AND LOWER(a.ZNAME) LIKE LOWER('%${appName}%')`; } else if (appId) { query += ` AND LOWER(a.ZAPPID) = LOWER('${appId}')`; } query += ` ORDER BY k.ZSTORE, a.ZNAME, k.ZTEXT, d.ZDATE DESC`; const result = db.exec(query); const results = sqlResultToObjects(result); // Group by app, keyword, and store const grouped = results.reduce((acc: any, row: any) => { const key = `${row.app_name}|${row.keyword}|${row.store}`; if (!acc[key]) { acc[key] = { keyword: row.keyword, app: row.app_name, store: row.store, rankings: [], }; } acc[key].rankings.push({ date: convertCoreDataDate(row.date), ranking: row.ranking, }); return acc; }, {}); return Object.values(grouped); } // Tool: list_apps async function listApps(): Promise<any[]> { ensureFreshDatabase(); const query = ` SELECT ZNAME as name, ZAPPID as app_id, ZDEVELOPER as developer, ZPLATFORM as platform, ZLASTUPDATERATINGS as last_update, (SELECT COUNT(*) FROM ZKEYWORD WHERE ZAPPLICATION = a.Z_PK) as keyword_count, (SELECT GROUP_CONCAT(DISTINCT ZSTORE) FROM ZKEYWORD WHERE ZAPPLICATION = a.Z_PK) as stores FROM ZAPPLICATION a ORDER BY ZNAME `; const result = db.exec(query); const results = sqlResultToObjects(result); return results.map((row: any) => ({ name: row.name, appId: row.app_id, developer: row.developer, platform: row.platform, lastUpdate: convertCoreDataDate(row.last_update), keywordCount: row.keyword_count, stores: row.stores ? row.stores.split(',').sort() : [], })); } // Tool: get_app_keywords async function getAppKeywords(params: AppKeywordsParams): Promise<any[]> { ensureFreshDatabase(); const { appName, appId, store } = params; if (!appName && !appId) { throw new Error("Either appName or appId must be provided"); } let query = ` SELECT k.ZTEXT as keyword, k.ZCURRENTRANKING as current_ranking, k.ZPREVIOUSRANKING as previous_ranking, k.ZDIFFICULTY as difficulty, k.ZPOPULARITY as popularity, k.ZAPPSCOUNT as apps_count, k.ZSTORE as store, k.ZLASTUPDATE as last_update FROM ZKEYWORD k JOIN ZAPPLICATION a ON k.ZAPPLICATION = a.Z_PK WHERE `; if (appName) { query += `LOWER(a.ZNAME) LIKE LOWER('%${appName}%')`; } else if (appId) { query += `LOWER(a.ZAPPID) = LOWER('${appId}')`; } if (store) { query += ` AND LOWER(k.ZSTORE) = LOWER('${store}')`; } query += ` ORDER BY k.ZCURRENTRANKING ASC NULLS LAST`; const result = db.exec(query); const results = sqlResultToObjects(result); return results.map((row: any) => ({ keyword: row.keyword, currentRanking: row.current_ranking, previousRanking: row.previous_ranking, difficulty: row.difficulty, popularity: row.popularity, appsCount: row.apps_count, store: row.store, lastUpdate: convertCoreDataDate(row.last_update), rankingChange: row.previous_ranking && row.current_ranking ? row.previous_ranking - row.current_ranking : null, })); } // Tool: get_keyword_trends async function getKeywordTrends(params: KeywordTrendsParams): Promise<TrendAnalysis[]> { ensureFreshDatabase(); const { keyword, appName, appId, period = 'month' } = params; let daysBack: number; switch (period) { case 'week': daysBack = 7; break; case 'month': daysBack = 30; break; case 'year': daysBack = 365; break; case 'all': daysBack = 10000; break; } const dateLimit = Date.now() / 1000 - CORE_DATA_EPOCH_OFFSET - (daysBack * 24 * 60 * 60); let query = ` SELECT a.ZNAME as app_name, k.ZTEXT as keyword, d.ZRANKING as ranking, d.ZDATE as date FROM ZDATAPOINT d JOIN ZKEYWORD k ON d.ZKEYWORD = k.Z_PK JOIN ZAPPLICATION a ON k.ZAPPLICATION = a.Z_PK WHERE LOWER(k.ZTEXT) LIKE LOWER('%${keyword}%') AND d.ZDATE > ${dateLimit} `; if (appName) { query += ` AND LOWER(a.ZNAME) LIKE LOWER('%${appName}%')`; } else if (appId) { query += ` AND LOWER(a.ZAPPID) = LOWER('${appId}')`; } query += ` ORDER BY a.ZNAME, k.ZTEXT, d.ZDATE ASC`; const result = db.exec(query); const results = sqlResultToObjects(result); // Group by app and keyword for trend analysis const grouped = results.reduce((acc: any, row: any) => { const key = `${row.app_name}|${row.keyword}`; if (!acc[key]) { acc[key] = { app: row.app_name, keyword: row.keyword, rankings: [], }; } acc[key].rankings.push({ date: convertCoreDataDate(row.date), ranking: row.ranking, }); return acc; }, {}); return Object.values(grouped).map((group: any) => { const rankings = group.rankings.map((r: any) => r.ranking); const avgRanking = rankings.reduce((a: number, b: number) => a + b, 0) / rankings.length; const minRanking = Math.min(...rankings); const maxRanking = Math.max(...rankings); const volatility = calculateStdDev(rankings); // Determine trend let trend: 'improving' | 'declining' | 'stable'; if (rankings.length >= 2) { const firstHalf = rankings.slice(0, Math.floor(rankings.length / 2)); const secondHalf = rankings.slice(Math.floor(rankings.length / 2)); const firstAvg = firstHalf.reduce((a: number, b: number) => a + b, 0) / firstHalf.length; const secondAvg = secondHalf.reduce((a: number, b: number) => a + b, 0) / secondHalf.length; if (secondAvg < firstAvg - 5) trend = 'improving'; else if (secondAvg > firstAvg + 5) trend = 'declining'; else trend = 'stable'; } else { trend = 'stable'; } return { keyword: group.keyword, app: group.app, store: group.store, period, dataPoints: rankings.length, avgRanking: Math.round(avgRanking * 10) / 10, minRanking, maxRanking, volatility: Math.round(volatility * 10) / 10, trend, rankingChanges: group.rankings.slice(-10), // Last 10 data points }; }); } // Tool: compare_rankings async function compareRankings(params: CompareRankingsParams): Promise<any[]> { ensureFreshDatabase(); const { keyword, date1, date2, appName, appId } = params; // Convert dates to Core Data timestamps const timestamp1 = new Date(date1).getTime() / 1000 - CORE_DATA_EPOCH_OFFSET; const timestamp2 = new Date(date2).getTime() / 1000 - CORE_DATA_EPOCH_OFFSET; const appFilter = appName ? `AND LOWER(a.ZNAME) LIKE LOWER('%${appName}%')` : appId ? `AND LOWER(a.ZAPPID) = LOWER('${appId}')` : ''; let query = ` WITH date1_rankings AS ( SELECT a.ZNAME as app_name, k.ZTEXT as keyword, d.ZRANKING as ranking, ABS(d.ZDATE - ${timestamp1}) as date_diff FROM ZDATAPOINT d JOIN ZKEYWORD k ON d.ZKEYWORD = k.Z_PK JOIN ZAPPLICATION a ON k.ZAPPLICATION = a.Z_PK WHERE LOWER(k.ZTEXT) LIKE LOWER('%${keyword}%') ${appFilter} ORDER BY date_diff LIMIT 1 ), date2_rankings AS ( SELECT a.ZNAME as app_name, k.ZTEXT as keyword, d.ZRANKING as ranking, ABS(d.ZDATE - ${timestamp2}) as date_diff FROM ZDATAPOINT d JOIN ZKEYWORD k ON d.ZKEYWORD = k.Z_PK JOIN ZAPPLICATION a ON k.ZAPPLICATION = a.Z_PK WHERE LOWER(k.ZTEXT) LIKE LOWER('%${keyword}%') ${appFilter} ORDER BY date_diff LIMIT 1 ) SELECT COALESCE(d1.app_name, d2.app_name) as app_name, COALESCE(d1.keyword, d2.keyword) as keyword, d1.ranking as ranking1, d2.ranking as ranking2 FROM date1_rankings d1 FULL OUTER JOIN date2_rankings d2 ON d1.app_name = d2.app_name AND d1.keyword = d2.keyword `; const result = db.exec(query); const results = sqlResultToObjects(result); return results.map((row: any) => ({ app: row.app_name, keyword: row.keyword, date1, date2, ranking1: row.ranking1, ranking2: row.ranking2, change: row.ranking1 && row.ranking2 ? row.ranking1 - row.ranking2 : null, percentChange: row.ranking1 && row.ranking2 ? Math.round(((row.ranking1 - row.ranking2) / row.ranking1) * 100 * 10) / 10 : null, })); } // Helper function for linear regression function linearRegression(data: Array<{x: number, y: number}>): {slope: number, intercept: number, r2: number} { const n = data.length; const sumX = data.reduce((a, b) => a + b.x, 0); const sumY = data.reduce((a, b) => a + b.y, 0); const sumXY = data.reduce((a, b) => a + b.x * b.y, 0); const sumX2 = data.reduce((a, b) => a + b.x * b.x, 0); const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); const intercept = (sumY - slope * sumX) / n; // Calculate R-squared const yMean = sumY / n; const ssTotal = data.reduce((a, b) => a + Math.pow(b.y - yMean, 2), 0); const ssRes = data.reduce((a, b) => a + Math.pow(b.y - (slope * b.x + intercept), 2), 0); const r2 = 1 - (ssRes / ssTotal); return { slope, intercept, r2 }; } // Tool: get_keyword_competitors async function getKeywordCompetitors(params: KeywordCompetitorsParams): Promise<CompetitorResult[]> { ensureFreshDatabase(); const { keyword, store, limit = 10 } = params; // Try to get competitors from ZBINARYDATA first let binaryQuery = ` SELECT k.ZTEXT as keyword, k.ZSTORE as store, b.ZRANKEDAPPS as ranked_apps, k.ZLASTUPDATE as last_update FROM ZBINARYDATA b JOIN ZKEYWORD k ON b.ZKEYWORD = k.Z_PK WHERE LOWER(k.ZTEXT) LIKE LOWER('%${keyword}%') `; if (store) { binaryQuery += ` AND LOWER(k.ZSTORE) = LOWER('${store}')`; } binaryQuery += ` LIMIT 1`; const binaryResult = db.exec(binaryQuery); const binaryData = sqlResultToObjects(binaryResult); let competitors = []; if (binaryData.length > 0 && binaryData[0].ranked_apps) { // Parse JSON data from ZBINARYDATA using helper function competitors = parseCompetitorData(binaryData[0].ranked_apps, keyword, limit); if (competitors.length > 0) { return [{ keyword: keyword, store: binaryData[0].store, competitors: competitors, lastUpdate: convertCoreDataDate(binaryData[0].last_update), }]; } } // Fallback: Get apps currently ranking for this keyword let fallbackQuery = ` SELECT DISTINCT a.ZNAME as app_name, a.ZAPPID as app_id, k.ZCURRENTRANKING as ranking, k.ZTEXT as keyword, k.ZSTORE as store, k.ZLASTUPDATE as last_update FROM ZKEYWORD k JOIN ZAPPLICATION a ON k.ZAPPLICATION = a.Z_PK WHERE LOWER(k.ZTEXT) LIKE LOWER('%${keyword}%') AND k.ZCURRENTRANKING IS NOT NULL `; if (store) { fallbackQuery += ` AND LOWER(k.ZSTORE) = LOWER('${store}')`; } fallbackQuery += ` ORDER BY k.ZSTORE, k.ZCURRENTRANKING ASC LIMIT ${limit}`; const fallbackResult = db.exec(fallbackQuery); const fallbackData = sqlResultToObjects(fallbackResult); // Group by store const grouped = fallbackData.reduce((acc: any, row: any) => { const storeKey = row.store; if (!acc[storeKey]) { acc[storeKey] = { keyword: keyword, store: row.store, competitors: [], lastUpdate: convertCoreDataDate(row.last_update), }; } acc[storeKey].competitors.push({ appName: row.app_name, appId: row.app_id, ranking: row.ranking, }); return acc; }, {}); return Object.values(grouped); } // Tool: get_keyword_recommendations async function getKeywordRecommendations(params: KeywordRecommendationsParams): Promise<KeywordRecommendation> { ensureFreshDatabase(); const { keyword, appName, appId, store, limit = 10 } = params; // Try to get recommendations from ZBINARYDATA const binaryQuery = ` SELECT k.ZTEXT as keyword, b.ZRECOMMENDATION as recommendation FROM ZBINARYDATA b JOIN ZKEYWORD k ON b.ZKEYWORD = k.Z_PK WHERE LOWER(k.ZTEXT) LIKE LOWER('%${keyword}%') LIMIT 1 `; const binaryResult = db.exec(binaryQuery); const binaryData = sqlResultToObjects(binaryResult); let recommendations = []; let source: 'binary_data' | 'similar_keywords' = 'similar_keywords'; if (binaryData.length > 0 && binaryData[0].recommendation) { // Try to parse recommendation data try { // For now, we'll use a fallback method console.error("Recommendation parsing not implemented, using fallback method"); } catch (e) { console.error("Failed to parse recommendation data:", e); } } // Fallback: Find similar keywords based on shared apps and similar characteristics let similarQuery = ` SELECT DISTINCT k2.ZTEXT as recommended_keyword FROM ZKEYWORD k1 JOIN ZKEYWORD k2 ON k1.ZAPPLICATION = k2.ZAPPLICATION WHERE LOWER(k1.ZTEXT) LIKE LOWER('%${keyword}%') AND k2.ZTEXT != k1.ZTEXT AND ABS(COALESCE(k2.ZDIFFICULTY, 50) - COALESCE(k1.ZDIFFICULTY, 50)) <= 20 AND ABS(COALESCE(k2.ZPOPULARITY, 50) - COALESCE(k1.ZPOPULARITY, 50)) <= 30 `; if (appName) { similarQuery += ` AND EXISTS (SELECT 1 FROM ZAPPLICATION a WHERE a.Z_PK = k2.ZAPPLICATION AND LOWER(a.ZNAME) LIKE LOWER('%${appName}%'))`; } else if (appId) { similarQuery += ` AND EXISTS (SELECT 1 FROM ZAPPLICATION a WHERE a.Z_PK = k2.ZAPPLICATION AND LOWER(a.ZAPPID) = LOWER('${appId}'))`; } similarQuery += ` LIMIT ${limit}`; const similarResult = db.exec(similarQuery); const similarData = sqlResultToObjects(similarResult); recommendations = similarData.map((row: any) => row.recommended_keyword); return { keyword: keyword, store: store || 'all', recommendedKeywords: recommendations, source: source, }; } // Tool: analyze_competitive_landscape async function analyzeCompetitiveLandscape(params: CompetitiveLandscapeParams): Promise<CompetitiveLandscape> { ensureFreshDatabase(); const { appName, appId, store, limit = 10 } = params; if (!appName && !appId) { throw new Error("Either appName or appId must be provided"); } const appFilter = appName ? `LOWER(a.ZNAME) LIKE LOWER('%${appName}%')` : `LOWER(a.ZAPPID) = LOWER('${appId}')`; // Get the app's keywords let appKeywordsQuery = ` SELECT a.ZNAME as app_name, k.ZTEXT as keyword, k.ZCURRENTRANKING as ranking, k.ZSTORE as store FROM ZKEYWORD k JOIN ZAPPLICATION a ON k.ZAPPLICATION = a.Z_PK WHERE ${appFilter} `; if (store) { appKeywordsQuery += ` AND LOWER(k.ZSTORE) = LOWER('${store}')`; } const appKeywordsResult = db.exec(appKeywordsQuery); const appKeywords = sqlResultToObjects(appKeywordsResult); if (appKeywords.length === 0) { throw new Error("No keywords found for the specified app"); } const appName2 = appKeywords[0].app_name; const keywordList = appKeywords.map((k: any) => `'${k.keyword.replace(/'/g, "''")}'`).join(','); // Find real competitors from App Store binary data let binaryCompetitorQuery = ` SELECT k.ZTEXT as keyword, k.ZSTORE as store, b.ZRANKEDAPPS as ranked_apps FROM ZBINARYDATA b JOIN ZKEYWORD k ON b.ZKEYWORD = k.Z_PK JOIN ZAPPLICATION a ON k.ZAPPLICATION = a.Z_PK WHERE ${appFilter} AND b.ZRANKEDAPPS IS NOT NULL `; if (store) { binaryCompetitorQuery += ` AND LOWER(k.ZSTORE) = LOWER('${store}')`; } const binaryCompetitorResult = db.exec(binaryCompetitorQuery); const binaryCompetitorData = sqlResultToObjects(binaryCompetitorResult); // Parse competitor data from JSON const competitorMap = new Map(); let totalKeywordsWithData = 0; for (const row of binaryCompetitorData) { const competitors = parseCompetitorData(row.ranked_apps, row.keyword, 10); if (competitors.length > 0) { totalKeywordsWithData++; competitors.forEach((competitor) => { if (!competitorMap.has(competitor.appName)) { competitorMap.set(competitor.appName, { name: competitor.appName, appId: competitor.appId, category: competitor.category, sharedKeywords: 0, avgRanking: 0, totalRankings: 0, }); } const comp = competitorMap.get(competitor.appName); comp.sharedKeywords++; comp.totalRankings += competitor.ranking; }); } } // Convert to array and calculate averages const competitors = Array.from(competitorMap.values()) .filter(c => c.name !== appName2) // Remove our own app .map(c => ({ competitorName: c.name, sharedKeywords: c.sharedKeywords, avgRankingDiff: c.totalRankings / c.sharedKeywords - 50, // Rough estimate dominanceScore: Math.round((c.sharedKeywords / totalKeywordsWithData) * 10), category: c.category })) .sort((a, b) => b.sharedKeywords - a.sharedKeywords) .slice(0, limit); // Calculate market share let marketShareQuery = ` SELECT SUM(CASE WHEN k.ZCURRENTRANKING <= 10 THEN 1 ELSE 0 END) as top10, SUM(CASE WHEN k.ZCURRENTRANKING <= 25 THEN 1 ELSE 0 END) as top25, SUM(CASE WHEN k.ZCURRENTRANKING <= 50 THEN 1 ELSE 0 END) as top50, COUNT(*) as total FROM ZKEYWORD k JOIN ZAPPLICATION a ON k.ZAPPLICATION = a.Z_PK WHERE ${appFilter} AND k.ZCURRENTRANKING IS NOT NULL `; if (store) { marketShareQuery += ` AND LOWER(k.ZSTORE) = LOWER('${store}')`; } const marketShareResult = db.exec(marketShareQuery); const marketShareData = sqlResultToObjects(marketShareResult)[0] || {}; const topCompetitors = competitors; const total = marketShareData.total || 1; const competitiveIntensity = topCompetitors.length >= 7 ? 'high' : topCompetitors.length >= 4 ? 'medium' : 'low'; return { app: appName2, store: store || 'all', totalKeywords: appKeywords.length, topCompetitors: topCompetitors, marketShare: { top10: Math.round(((marketShareData.top10 || 0) / total) * 100), top25: Math.round(((marketShareData.top25 || 0) / total) * 100), top50: Math.round(((marketShareData.top50 || 0) / total) * 100), }, competitiveIntensity: competitiveIntensity, }; } // Tool: calculate_keyword_opportunity async function calculateKeywordOpportunity(params: KeywordOpportunityParams): Promise<KeywordOpportunity[]> { ensureFreshDatabase(); const { appName, appId, store, minPopularity = 30, maxDifficulty = 70 } = params; let query = ` SELECT k.ZTEXT as keyword, k.ZSTORE as store, k.ZCURRENTRANKING as current_ranking, COALESCE(k.ZDIFFICULTY, 50) as difficulty, COALESCE(k.ZPOPULARITY, 50) as popularity, COALESCE(k.ZAPPSCOUNT, 100) as competition, a.ZNAME as app_name FROM ZKEYWORD k JOIN ZAPPLICATION a ON k.ZAPPLICATION = a.Z_PK WHERE COALESCE(k.ZPOPULARITY, 50) >= ${minPopularity} AND COALESCE(k.ZDIFFICULTY, 50) <= ${maxDifficulty} `; if (appName) { query += ` AND LOWER(a.ZNAME) LIKE LOWER('%${appName}%')`; } else if (appId) { query += ` AND LOWER(a.ZAPPID) = LOWER('${appId}')`; } if (store) { query += ` AND LOWER(k.ZSTORE) = LOWER('${store}')`; } query += ` ORDER BY k.ZSTORE, k.ZPOPULARITY DESC, k.ZDIFFICULTY ASC`; const result = db.exec(query); const keywords = sqlResultToObjects(result); return keywords.map((k: any) => { // Opportunity Score Formula: // (Popularity × (101 - Current Ranking)) / (Difficulty × Competition/100) const rankingFactor = k.current_ranking ? (101 - k.current_ranking) : 50; const opportunityScore = Math.round( (k.popularity * rankingFactor) / (k.difficulty * (k.competition / 100)) ); let reasoning = ''; if (opportunityScore >= 80) { reasoning = 'Excellent opportunity: High popularity, low competition'; } else if (opportunityScore >= 60) { reasoning = 'Good opportunity: Balanced metrics with room for improvement'; } else if (opportunityScore >= 40) { reasoning = 'Moderate opportunity: Consider if aligned with strategy'; } else { reasoning = 'Lower priority: High competition or difficulty'; } if (!k.current_ranking) { reasoning += ' (Not currently ranking - potential new opportunity)'; } else if (k.current_ranking <= 10) { reasoning += ' (Already ranking well - focus on maintenance)'; } return { keyword: k.keyword, store: k.store, opportunityScore: opportunityScore, currentRanking: k.current_ranking, difficulty: k.difficulty, popularity: k.popularity, competition: k.competition, reasoning: reasoning, }; }).sort((a, b) => b.opportunityScore - a.opportunityScore); } // Tool: detect_ranking_anomalies async function detectRankingAnomalies(params: RankingAnomalyParams): Promise<RankingAnomaly[]> { ensureFreshDatabase(); const { appName, appId, daysBack = 7, threshold = 10 } = params; // Get anomalies with more historical context let query = ` SELECT a.ZNAME as app_name, k.ZTEXT as keyword, k.ZCURRENTRANKING as current_ranking, k.ZPREVIOUSRANKING as previous_ranking, k.ZLASTUPDATE as last_update, (SELECT MAX(d.ZDATE) FROM ZDATAPOINT d WHERE d.ZKEYWORD = k.Z_PK AND d.ZRANKING = k.ZPREVIOUSRANKING AND d.ZDATE < k.ZLASTUPDATE ORDER BY d.ZDATE DESC LIMIT 1) as previous_date FROM ZKEYWORD k JOIN ZAPPLICATION a ON k.ZAPPLICATION = a.Z_PK WHERE k.ZCURRENTRANKING IS NOT NULL AND k.ZPREVIOUSRANKING IS NOT NULL AND ABS(k.ZCURRENTRANKING - k.ZPREVIOUSRANKING) >= ${threshold} `; if (appName) { query += ` AND LOWER(a.ZNAME) LIKE LOWER('%${appName}%')`; } else if (appId) { query += ` AND LOWER(a.ZAPPID) = LOWER('${appId}')`; } query += ` ORDER BY ABS(k.ZCURRENTRANKING - k.ZPREVIOUSRANKING) DESC`; const result = db.exec(query); const anomalies = sqlResultToObjects(result); return anomalies.map((a: any) => { const change = a.current_ranking - a.previous_ranking; const percentChange = Math.round((change / a.previous_ranking) * 100); const isUnranked = (ranking: number) => ranking >= 1000; const wasUnranked = isUnranked(a.previous_ranking); const isNowUnranked = isUnranked(a.current_ranking); let anomalyType: 'sudden_drop' | 'sudden_rise' | 'volatility_spike' | 'new_ranking' | 'lost_ranking'; let severity: 'low' | 'medium' | 'high' | 'critical'; let interpretation: string; // Calculate days since previous ranking if available const lastUpdate = convertCoreDataDate(a.last_update); const previousDate = convertCoreDataDate(a.previous_date); let daysSincePrevious = null; if (lastUpdate && previousDate) { const daysDiff = Math.floor((new Date(lastUpdate).getTime() - new Date(previousDate).getTime()) / (1000 * 60 * 60 * 24)); daysSincePrevious = daysDiff; } // Classify the anomaly type and severity if (wasUnranked && !isNowUnranked) { // App started ranking for this keyword anomalyType = 'new_ranking'; interpretation = `App started ranking for "${a.keyword}" at position ${a.current_ranking}. This keyword was previously not in the top rankings.`; if (a.current_ranking <= 25) severity = 'high'; else if (a.current_ranking <= 50) severity = 'medium'; else severity = 'low'; } else if (!wasUnranked && isNowUnranked) { // App lost ranking for this keyword anomalyType = 'lost_ranking'; interpretation = `App lost ranking for "${a.keyword}" (was at position ${a.previous_ranking}). The keyword is no longer in the top rankings.`; if (a.previous_ranking <= 25) severity = 'critical'; else if (a.previous_ranking <= 50) severity = 'high'; else if (a.previous_ranking <= 100) severity = 'medium'; else severity = 'low'; } else if (change > 0) { // Ranking dropped (higher number = worse ranking) anomalyType = 'sudden_drop'; if (change >= 50) { severity = 'critical'; interpretation = `Major ranking drop for "${a.keyword}" from ${a.previous_ranking} to ${a.current_ranking}. This requires immediate attention.`; } else if (change >= 30) { severity = 'high'; interpretation = `Significant ranking drop for "${a.keyword}" from ${a.previous_ranking} to ${a.current_ranking}. Consider investigating causes.`; } else if (change >= 20) { severity = 'medium'; interpretation = `Moderate ranking drop for "${a.keyword}" from ${a.previous_ranking} to ${a.current_ranking}. Monitor for continued decline.`; } else { severity = 'low'; interpretation = `Minor ranking drop for "${a.keyword}" from ${a.previous_ranking} to ${a.current_ranking}. Normal volatility range.`; } } else { // Ranking improved (lower number = better ranking) anomalyType = 'sudden_rise'; const improvement = Math.abs(change); if (improvement >= 50) { severity = 'high'; interpretation = `Major ranking improvement for "${a.keyword}" from ${a.previous_ranking} to ${a.current_ranking}. Excellent performance!`; } else if (improvement >= 30) { severity = 'medium'; interpretation = `Good ranking improvement for "${a.keyword}" from ${a.previous_ranking} to ${a.current_ranking}. Positive trend.`; } else { severity = 'low'; interpretation = `Small ranking improvement for "${a.keyword}" from ${a.previous_ranking} to ${a.current_ranking}. Stable performance.`; } } // Add timing context to interpretation if (daysSincePrevious !== null) { if (daysSincePrevious === 0) { interpretation += ` This change happened very recently.`; } else if (daysSincePrevious === 1) { interpretation += ` This change happened yesterday.`; } else if (daysSincePrevious <= 7) { interpretation += ` This change happened ${daysSincePrevious} days ago.`; } else { interpretation += ` This change happened ${daysSincePrevious} days ago - it may be an older update.`; } } return { keyword: a.keyword, app: a.app_name, store: a.store, previousRanking: a.previous_ranking, currentRanking: a.current_ranking, change: change, percentChange: percentChange, anomalyType: anomalyType, severity: severity, detectedDate: lastUpdate, previousDate: previousDate, daysSincePrevious: daysSincePrevious, interpretation: interpretation, }; }); } // Tool: predict_ranking_trends async function predictRankingTrends(params: RankingPredictionParams): Promise<RankingPrediction[]> { ensureFreshDatabase(); const { keyword, appName, appId, store, daysForward = 7 } = params; // Get historical data - group by keyword and app to handle multiple matches const dateLimit = Date.now() / 1000 - CORE_DATA_EPOCH_OFFSET - (30 * 24 * 60 * 60); // First, get distinct keyword/app combinations // Default to exact match, but allow fuzzy search with % const useExactMatch = !keyword.includes('%'); const searchKeyword = keyword.replace(/%/g, ''); // Remove % for exact match let distinctQuery = ` SELECT DISTINCT a.ZNAME as app_name, a.ZAPPID as app_id, k.ZTEXT as keyword, k.ZSTORE as store, k.Z_PK as keyword_id, k.ZCURRENTRANKING as current_ranking FROM ZKEYWORD k JOIN ZAPPLICATION a ON k.ZAPPLICATION = a.Z_PK WHERE ${useExactMatch ? `LOWER(k.ZTEXT) = LOWER('${searchKeyword}')` : `LOWER(k.ZTEXT) LIKE LOWER('${keyword}')`} `; if (store) { distinctQuery += ` AND LOWER(k.ZSTORE) = LOWER('${store}')`; } if (appName) { distinctQuery += ` AND LOWER(a.ZNAME) LIKE LOWER('%${appName}%')`; } else if (appId) { distinctQuery += ` AND LOWER(a.ZAPPID) = LOWER('${appId}')`; } distinctQuery += ` ORDER BY k.ZSTORE, k.ZTEXT, a.ZNAME`; const distinctResult = db.exec(distinctQuery); const distinctCombos = sqlResultToObjects(distinctResult); if (distinctCombos.length === 0) { throw new Error("No matching keywords found"); } const predictions: RankingPrediction[] = []; // Process each keyword/app combination for (const combo of distinctCombos) { const historyQuery = ` SELECT d.ZRANKING as ranking, d.ZDATE as date FROM ZDATAPOINT d WHERE d.ZKEYWORD = ${combo.keyword_id} AND d.ZDATE > ${dateLimit} ORDER BY d.ZDATE ASC `; const historyResult = db.exec(historyQuery); const historyData = sqlResultToObjects(historyResult); if (historyData.length < 3) { // Skip this combination if insufficient data continue; } // Prepare data for linear regression const startDate = historyData[0].date; const regressionData = historyData.map((d: any) => ({ x: (d.date - startDate) / (24 * 60 * 60), // Days from start y: d.ranking, })); const { slope, intercept, r2 } = linearRegression(regressionData); // Predict future ranking const currentDay = regressionData[regressionData.length - 1].x; const predictedDay = currentDay + daysForward; const predictedRanking = Math.max(1, Math.min(100, Math.round(slope * predictedDay + intercept))); const currentRanking = combo.current_ranking || historyData[historyData.length - 1].ranking; const predictedChange = predictedRanking - currentRanking; let trend: 'improving' | 'declining' | 'stable'; if (slope < -0.5) trend = 'improving'; else if (slope > 0.5) trend = 'declining'; else trend = 'stable'; const confidence = Math.min(95, Math.round(r2 * 100)); const futureDate = new Date(Date.now() + daysForward * 24 * 60 * 60 * 1000); predictions.push({ keyword: combo.keyword, app: combo.app_name, store: combo.store, currentRanking: currentRanking, predictedRanking: predictedRanking, confidence: confidence, trend: trend, predictedChange: predictedChange, predictionDate: futureDate.toISOString(), methodology: `Linear regression (R² = ${r2.toFixed(3)})`, dataPoints: historyData.length, }); } if (predictions.length === 0) { throw new Error("Insufficient historical data for any keyword/app combination"); } // Sort by confidence and then by current ranking predictions.sort((a, b) => { if (Math.abs(a.confidence - b.confidence) > 10) { return b.confidence - a.confidence; } return (a.currentRanking || 100) - (b.currentRanking || 100); }); return predictions; } // Tool: find_low_competition_keywords async function findLowCompetitionKeywords(params: LowCompetitionParams): Promise<LowCompetitionKeyword[]> { ensureFreshDatabase(); const { store, maxDifficulty = 30, minPopularity = 40, limit = 20 } = params; let query = ` SELECT DISTINCT k.ZTEXT as keyword, COALESCE(k.ZDIFFICULTY, 50) as difficulty, COALESCE(k.ZPOPULARITY, 50) as popularity, COALESCE(k.ZAPPSCOUNT, 100) as apps_count, k.ZSTORE as store FROM ZKEYWORD k WHERE COALESCE(k.ZDIFFICULTY, 50) <= ${maxDifficulty} AND COALESCE(k.ZPOPULARITY, 50) >= ${minPopularity} `; if (store) { query += ` AND LOWER(k.ZSTORE) = LOWER('${store}')`; } query += ` ORDER BY k.ZSTORE, (COALESCE(k.ZPOPULARITY, 50) * 1.0 / COALESCE(k.ZDIFFICULTY, 50)) DESC LIMIT ${limit}`; const result = db.exec(query); const keywords = sqlResultToObjects(result); return keywords.map((k: any) => ({ keyword: k.keyword, difficulty: k.difficulty, popularity: k.popularity, competitionScore: Math.round((k.popularity / k.difficulty) * 10) / 10, appsCount: k.apps_count, store: k.store, })); } // Tool: get_app_ratings async function getAppRatings(params: AppRatingsParams): Promise<any[]> { ensureFreshDatabase(); const { appName, appId, store, daysBack = 30 } = params; if (!appName && !appId) { throw new Error("Either appName or appId must be provided"); } const dateLimit = Date.now() / 1000 - CORE_DATA_EPOCH_OFFSET - (daysBack * 24 * 60 * 60); // Get historical rating data points const appFilter = appName ? `LOWER(a.ZNAME) LIKE LOWER('%${appName}%')` : `LOWER(a.ZAPPID) = LOWER('${appId}')`; let query = ` SELECT a.ZNAME as app_name, r.ZCOUNTRYNAME as country, r.ZSTORE as store, rdp.ZAVERAGEUSERRATING as avg_rating, rdp.ZUSERRATINGCOUNT as rating_count, rdp.ZDATE as date, LAG(rdp.ZAVERAGEUSERRATING) OVER (PARTITION BY r.Z_PK ORDER BY rdp.ZDATE) as prev_rating, LAG(rdp.ZUSERRATINGCOUNT) OVER (PARTITION BY r.Z_PK ORDER BY rdp.ZDATE) as prev_count FROM ZRATINGDATAPOINT rdp JOIN ZRATING r ON rdp.ZRATING = r.Z_PK JOIN Z_1RATINGS rel ON r.Z_PK = rel.Z_6RATINGS JOIN ZAPPLICATION a ON rel.Z_1APPLICATIONS = a.Z_PK WHERE ${appFilter} AND rdp.ZDATE > ${dateLimit} `; if (store) { query += ` AND LOWER(r.ZSTORE) = LOWER('${store}')`; } query += ` ORDER BY r.ZSTORE, rdp.ZDATE DESC`; const result = db.exec(query); const results = sqlResultToObjects(result); return results.map((row: any) => ({ app: row.app_name, country: row.country, store: row.store, averageRating: Math.round(row.avg_rating * 100) / 100, ratingCount: row.rating_count, date: convertCoreDataDate(row.date), ratingChange: row.prev_rating ? Math.round((row.avg_rating - row.prev_rating) * 100) / 100 : null, countChange: row.prev_count ? row.rating_count - row.prev_count : null, previousRating: row.prev_rating ? Math.round(row.prev_rating * 100) / 100 : null, previousCount: row.prev_count, })); } // Register tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "search_rankings", description: "Search current keyword rankings for apps", inputSchema: { type: "object", properties: { keyword: { type: "string", description: "Keyword to search for" }, store: { type: "string", description: "App store (optional - shows all stores if not provided)" }, appName: { type: "string", description: "Filter by app name (optional)" }, appId: { type: "string", description: "Filter by app ID (optional)" }, }, required: ["keyword"], }, }, { name: "get_historical_rankings", description: "Get historical ranking data for keywords", inputSchema: { type: "object", properties: { keyword: { type: "string", description: "Keyword to search for" }, appName: { type: "string", description: "Filter by app name (optional)" }, appId: { type: "string", description: "Filter by app ID (optional)" }, daysBack: { type: "number", description: "Number of days to look back (default: 30)" }, store: { type: "string", description: "App store (optional - shows all stores if not provided)" }, }, required: ["keyword"], }, }, { name: "list_apps", description: "List all tracked applications", inputSchema: { type: "object", properties: {}, }, }, { name: "get_app_keywords", description: "Get all keywords tracked for a specific app", inputSchema: { type: "object", properties: { appName: { type: "string", description: "App name to search for (optional if appId provided)" }, appId: { type: "string", description: "App ID to search for (optional if appName provided)" }, store: { type: "string", description: "Filter by app store (optional)" }, }, required: [], }, }, { name: "get_keyword_trends", description: "Analyze keyword ranking trends over time", inputSchema: { type: "object", properties: { keyword: { type: "string", description: "Keyword to analyze" }, appName: { type: "string", description: "Filter by app name (optional)" }, appId: { type: "string", description: "Filter by app ID (optional)" }, period: { type: "string", enum: ["week", "month", "year", "all"], description: "Time period to analyze (default: 'month')" }, }, required: ["keyword"], }, }, { name: "compare_rankings", description: "Compare keyword rankings between two dates", inputSchema: { type: "object", properties: { keyword: { type: "string", description: "Keyword to compare" }, date1: { type: "string", description: "First date (ISO format)" }, date2: { type: "string", description: "Second date (ISO format)" }, appName: { type: "string", description: "Filter by app name (optional)" }, appId: { type: "string", description: "Filter by app ID (optional)" }, }, required: ["keyword", "date1", "date2"], }, }, { name: "get_app_ratings", description: "Get app rating history", inputSchema: { type: "object", properties: { appName: { type: "string", description: "App name (optional if appId provided)" }, appId: { type: "string", description: "App ID (optional if appName provided)" }, store: { type: "string", description: "App store (optional - shows all stores if not provided)" }, daysBack: { type: "number", description: "Number of days to look back (default: 30)" }, }, required: [], }, }, { name: "get_version", description: "Get the server version", inputSchema: { type: "object", properties: {}, }, }, { name: "get_keyword_competitors", description: "Get competing apps that rank for a specific keyword with app categories", inputSchema: { type: "object", properties: { keyword: { type: "string", description: "Keyword to analyze" }, store: { type: "string", description: "App store (optional - shows all stores if not provided)" }, limit: { type: "number", description: "Maximum number of competitors to return (default: 10)" }, }, required: ["keyword"], }, }, { name: "get_keyword_recommendations", description: "Get keyword recommendations based on similar keywords and characteristics", inputSchema: { type: "object", properties: { keyword: { type: "string", description: "Base keyword for recommendations" }, appName: { type: "string", description: "Filter by app name (optional)" }, appId: { type: "string", description: "Filter by app ID (optional)" }, limit: { type: "number", description: "Maximum recommendations (default: 10)" }, }, required: ["keyword"], }, }, { name: "analyze_competitive_landscape", description: "Analyze the competitive landscape for an app, including top competitors and market share with categories", inputSchema: { type: "object", properties: { appName: { type: "string", description: "App name (optional if appId provided)" }, appId: { type: "string", description: "App ID (optional if appName provided)" }, store: { type: "string", description: "App store (optional - shows all stores if not provided)" }, limit: { type: "number", description: "Maximum number of competitors to return (default: 10)" }, }, required: [], }, }, { name: "calculate_keyword_opportunity", description: "Calculate opportunity scores for keywords based on difficulty, popularity, and competition", inputSchema: { type: "object", properties: { appName: { type: "string", description: "Filter by app name (optional)" }, appId: { type: "string", description: "Filter by app ID (optional)" }, store: { type: "string", description: "App store (optional - shows all stores if not provided)" }, minPopularity: { type: "number", description: "Minimum popularity score (default: 30)" }, maxDifficulty: { type: "number", description: "Maximum difficulty score (default: 70)" }, }, required: [], }, }, { name: "detect_ranking_anomalies", description: "Detect sudden ranking changes and anomalies that need attention", inputSchema: { type: "object", properties: { appName: { type: "string", description: "Filter by app name (optional)" }, appId: { type: "string", description: "Filter by app ID (optional)" }, daysBack: { type: "number", description: "Days to look back (default: 7)" }, threshold: { type: "number", description: "Minimum ranking change to detect (default: 10)" }, }, required: [], }, }, { name: "predict_ranking_trends", description: "Predict future ranking trends using linear regression on historical data for a specific store", inputSchema: { type: "object", properties: { keyword: { type: "string", description: "Keyword to predict (use % for fuzzy search)" }, appName: { type: "string", description: "Filter by app name (optional)" }, appId: { type: "string", description: "Filter by app ID (optional)" }, store: { type: "string", description: "App store country code (optional - shows all stores if not provided)" }, daysForward: { type: "number", description: "Days to predict forward (default: 7)" }, }, required: ["keyword"], }, }, { name: "find_low_competition_keywords", description: "Find keywords with low competition and good opportunity scores", inputSchema: { type: "object", properties: { store: { type: "string", description: "App store (optional - shows all stores if not provided)" }, maxDifficulty: { type: "number", description: "Maximum difficulty score (default: 30)" }, minPopularity: { type: "number", description: "Minimum popularity score (default: 40)" }, limit: { type: "number", description: "Maximum results to return (default: 20)" }, }, required: [], }, }, ], }; }); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; switch (name) { case "search_rankings": return { content: [{ type: "text", text: JSON.stringify(await searchRankings(args as unknown as SearchRankingsParams), null, 2) }] }; case "get_historical_rankings": return { content: [{ type: "text", text: JSON.stringify(await getHistoricalRankings(args as unknown as HistoricalRankingsParams), null, 2) }] }; case "list_apps": return { content: [{ type: "text", text: JSON.stringify(await listApps(), null, 2) }] }; case "get_app_keywords": return { content: [{ type: "text", text: JSON.stringify(await getAppKeywords(args as unknown as AppKeywordsParams), null, 2) }] }; case "get_keyword_trends": return { content: [{ type: "text", text: JSON.stringify(await getKeywordTrends(args as unknown as KeywordTrendsParams), null, 2) }] }; case "compare_rankings": return { content: [{ type: "text", text: JSON.stringify(await compareRankings(args as unknown as CompareRankingsParams), null, 2) }] }; case "get_app_ratings": return { content: [{ type: "text", text: JSON.stringify(await getAppRatings(args as unknown as AppRatingsParams), null, 2) }] }; case "get_version": ensureFreshDatabase(); const stats = statSync(DB_PATH); return { content: [{ type: "text", text: JSON.stringify({ version: VERSION, name: "astro-mcp-server", database_last_modified: new Date(stats.mtime).toISOString(), database_path: DB_PATH }, null, 2) }] }; case "get_keyword_competitors": return { content: [{ type: "text", text: JSON.stringify(await getKeywordCompetitors(args as unknown as KeywordCompetitorsParams), null, 2) }] }; case "get_keyword_recommendations": return { content: [{ type: "text", text: JSON.stringify(await getKeywordRecommendations(args as unknown as KeywordRecommendationsParams), null, 2) }] }; case "analyze_competitive_landscape": return { content: [{ type: "text", text: JSON.stringify(await analyzeCompetitiveLandscape(args as unknown as CompetitiveLandscapeParams), null, 2) }] }; case "calculate_keyword_opportunity": return { content: [{ type: "text", text: JSON.stringify(await calculateKeywordOpportunity(args as unknown as KeywordOpportunityParams), null, 2) }] }; case "detect_ranking_anomalies": return { content: [{ type: "text", text: JSON.stringify(await detectRankingAnomalies(args as unknown as RankingAnomalyParams), null, 2) }] }; case "predict_ranking_trends": return { content: [{ type: "text", text: JSON.stringify(await predictRankingTrends(args as unknown as RankingPredictionParams), null, 2) }] }; case "find_low_competition_keywords": return { content: [{ type: "text", text: JSON.stringify(await findLowCompetitionKeywords(args as unknown as LowCompetitionParams), null, 2) }] }; default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } } catch (error) { if (error instanceof McpError) throw error; throw new McpError( ErrorCode.InternalError, `Tool execution failed: ${error instanceof Error ? error.message : String(error)}` ); } }); // Start server async function main() { await initializeSqlJs(); loadDatabase(); const transport = new StdioServerTransport(); await server.connect(transport); console.error(`Astro MCP Server v${VERSION} running on stdio`); } main().catch((error) => { console.error("Fatal error:", error); process.exit(1); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/TimBroddin/astro-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server