#!/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);
});