Skip to main content
Glama
hablapro

Google Search Console MCP Server

by hablapro
seoAnalysisTools.ts50.2 kB
import { z } from "zod"; import type { Tool, ToolParams } from "./index"; import { getAuthenticatedClient, calculatePercentageChange, formatChange, formatDate, getDateRange, getExpectedCTR } from "../utils/gscHelper"; /** * Tool 1: Find High-Potential Keywords * Identifies keywords ranking 11-40 (striking distance) and high impression/low CTR opportunities */ export const findHighPotentialKeywordsTool: Tool = { name: "find_high_potential_keywords", description: "Find keywords with high potential for quick SEO wins: keywords ranking 11-40 (striking distance to page 1) and keywords with high impressions but low CTR (title/description optimization opportunities).", schema: z.object({ site_url: z.string({ required_error: "Site URL is required" }).min(1, { message: "Site URL cannot be empty" }) .describe("The URL of the site in Search Console"), days: z.number() .min(1).max(540) .default(28) .describe("Number of days to analyze (default: 28)"), min_impressions: z.number() .min(1) .default(100) .describe("Minimum impressions threshold (default: 100)"), ctr_threshold: z.number() .min(0).max(100) .default(2) .describe("CTR percentage below which is considered low (default: 2%)"), position_range_start: z.number() .min(1) .default(11) .describe("Start of striking distance position range (default: 11)"), position_range_end: z.number() .min(1) .default(40) .describe("End of striking distance position range (default: 40)") }), async execute(params: ToolParams, args: z.infer<typeof this.schema>) { const { site_url, days, min_impressions, ctr_threshold, position_range_start, position_range_end } = args; try { const client = await getAuthenticatedClient(params); if ('error' in client) { return { content: [{ type: "text", text: client.error }] }; } const { startDate, endDate } = getDateRange(days); // Query search analytics with query dimension const data = await client.querySearchAnalytics(site_url, { startDate, endDate, dimensions: ['query'], rowLimit: 5000 }); if (!data.rows || data.rows.length === 0) { return { content: [{ type: "text", text: `No search data available for ${site_url} in the last ${days} days.` }] }; } // Filter striking distance keywords (position 11-40, high impressions) const strikingDistance = data.rows .filter((row: any) => row.position >= position_range_start && row.position <= position_range_end && row.impressions >= min_impressions ) .sort((a: any, b: any) => b.impressions - a.impressions) .slice(0, 20); // Filter CTR opportunity keywords (high impressions, low CTR) const ctrOpportunities = data.rows .filter((row: any) => { const expectedCTR = getExpectedCTR(row.position); const actualCTR = row.ctr || 0; return row.impressions >= min_impressions && actualCTR < (ctr_threshold / 100) && actualCTR < expectedCTR * 0.5; // CTR is less than 50% of expected }) .sort((a: any, b: any) => b.impressions - a.impressions) .slice(0, 20); // Build output let output = `High-Potential Keywords Report for ${site_url}\n`; output += `${'='.repeat(60)}\n`; output += `Analysis Period: Last ${days} days (${startDate} to ${endDate})\n\n`; // Striking Distance Section output += `STRIKING DISTANCE KEYWORDS (Position ${position_range_start}-${position_range_end})\n`; output += `These keywords are close to page 1 - small ranking improvements could yield big traffic gains.\n`; output += `${'-'.repeat(60)}\n`; if (strikingDistance.length > 0) { output += `Keyword | Position | Impressions | Clicks | CTR | Potential\n`; output += `${'-'.repeat(60)}\n`; strikingDistance.forEach((row: any) => { const keyword = row.keys[0].substring(0, 35); const position = row.position.toFixed(1); const impressions = row.impressions; const clicks = row.clicks; const ctr = (row.ctr * 100).toFixed(2) + '%'; const potential = row.position <= 15 ? 'HIGH' : row.position <= 25 ? 'MEDIUM' : 'LOW'; output += `${keyword} | ${position} | ${impressions} | ${clicks} | ${ctr} | ${potential}\n`; }); output += `\nStriking Distance Recommendations:\n`; strikingDistance.slice(0, 3).forEach((row: any, i: number) => { output += `${i + 1}. "${row.keys[0]}" (pos ${row.position.toFixed(1)}) - Add internal links, improve content depth\n`; }); } else { output += `No keywords found in striking distance with ${min_impressions}+ impressions.\n`; } output += `\n`; // CTR Opportunity Section output += `CTR OPPORTUNITY KEYWORDS (High Impressions, Low CTR)\n`; output += `These keywords get seen but not clicked - improve titles/meta descriptions.\n`; output += `${'-'.repeat(60)}\n`; if (ctrOpportunities.length > 0) { output += `Keyword | Position | Impressions | CTR | Expected CTR | Gap\n`; output += `${'-'.repeat(60)}\n`; ctrOpportunities.forEach((row: any) => { const keyword = row.keys[0].substring(0, 30); const position = row.position.toFixed(1); const impressions = row.impressions; const actualCTR = (row.ctr * 100).toFixed(2) + '%'; const expectedCTR = (getExpectedCTR(row.position) * 100).toFixed(1) + '%'; const gap = '-' + ((getExpectedCTR(row.position) - row.ctr) * 100).toFixed(1) + 'pp'; output += `${keyword} | ${position} | ${impressions} | ${actualCTR} | ${expectedCTR} | ${gap}\n`; }); output += `\nCTR Improvement Recommendations:\n`; ctrOpportunities.slice(0, 3).forEach((row: any, i: number) => { output += `${i + 1}. "${row.keys[0]}" - CTR ${((getExpectedCTR(row.position) - row.ctr) / getExpectedCTR(row.position) * 100).toFixed(0)}% below expected. Review title tag and meta description.\n`; }); } else { output += `No significant CTR opportunities found.\n`; } // Summary output += `\nQUICK WINS SUMMARY\n`; output += `${'-'.repeat(60)}\n`; const totalStrikingImpressions = strikingDistance.reduce((sum: number, r: any) => sum + r.impressions, 0); const totalCTRImpressions = ctrOpportunities.reduce((sum: number, r: any) => sum + r.impressions, 0); output += `- ${strikingDistance.length} keywords in striking distance with ${totalStrikingImpressions.toLocaleString()} combined impressions\n`; output += `- ${ctrOpportunities.length} keywords with CTR significantly below position average\n`; return { content: [{ type: "text", text: output }] }; } catch (error: any) { return { content: [{ type: "text", text: `Error finding high-potential keywords: ${error.message}` }] }; } } }; /** * Tool 2: Check Page Experience * Analyzes mobile usability and crawl status (note: CWV not available via API) */ export const checkPageExperienceTool: Tool = { name: "check_page_experience", description: "Check page experience signals including mobile usability, crawl status, and indexing state. Note: Core Web Vitals (LCP, CLS, INP) are NOT available via GSC API - use PageSpeed Insights for CWV data.", schema: z.object({ site_url: z.string({ required_error: "Site URL is required" }).min(1).describe("The URL of the site in Search Console"), urls: z.string({ required_error: "URLs are required" }).min(1).describe("List of URLs to check, one per line (max 10)") }), async execute(params: ToolParams, args: z.infer<typeof this.schema>) { const { site_url, urls } = args; try { const client = await getAuthenticatedClient(params); if ('error' in client) { return { content: [{ type: "text", text: client.error }] }; } const urlList = urls.split(/[\n,]/).map(u => u.trim()).filter(u => u).slice(0, 10); let output = `Page Experience Report for ${site_url}\n`; output += `${'='.repeat(60)}\n`; output += `Note: Core Web Vitals (LCP, CLS, INP) are NOT available via GSC API.\n`; output += `Use PageSpeed Insights for CWV data: https://pagespeed.web.dev/\n\n`; let mobilePass = 0; let mobileFail = 0; let fetchIssues = 0; let robotsBlocked = 0; for (const url of urlList) { try { const result = await client.inspectUrl(site_url, url); const inspection = result.inspectionResult; output += `URL: ${url}\n`; output += `${'-'.repeat(60)}\n`; // Mobile usability const mobileUsability = inspection?.mobileUsabilityResult?.verdict || 'UNKNOWN'; if (mobileUsability === 'PASS') { mobilePass++; output += `Mobile Usability: PASS\n`; } else if (mobileUsability === 'FAIL') { mobileFail++; output += `Mobile Usability: FAIL\n`; const issues = inspection?.mobileUsabilityResult?.issues || []; if (issues.length > 0) { output += `Issues Found:\n`; issues.forEach((issue: any) => { output += ` - ${issue.issueType || issue.message || 'Unknown issue'}\n`; }); } } else { output += `Mobile Usability: ${mobileUsability}\n`; } // Indexing info const indexStatus = inspection?.indexStatusResult; if (indexStatus) { output += `Crawled As: ${indexStatus.crawledAs || 'UNKNOWN'}\n`; output += `Last Crawl: ${indexStatus.lastCrawlTime || 'Never'}\n`; output += `Page Fetch: ${indexStatus.pageFetchState || 'UNKNOWN'}\n`; output += `Robots.txt: ${indexStatus.robotsTxtState || 'UNKNOWN'}\n`; output += `Indexing State: ${indexStatus.indexingState || 'UNKNOWN'}\n`; if (indexStatus.pageFetchState && indexStatus.pageFetchState !== 'SUCCESSFUL') { fetchIssues++; } if (indexStatus.robotsTxtState === 'BLOCKED') { robotsBlocked++; } } output += `\n`; } catch (urlError: any) { output += `URL: ${url}\n`; output += `Error: ${urlError.message}\n\n`; } } // Summary output += `SUMMARY\n`; output += `${'-'.repeat(60)}\n`; output += `URLs Checked: ${urlList.length}\n`; output += `Mobile Usability Pass: ${mobilePass} (${((mobilePass / urlList.length) * 100).toFixed(0)}%)\n`; output += `Mobile Usability Fail: ${mobileFail} (${((mobileFail / urlList.length) * 100).toFixed(0)}%)\n`; output += `Fetch Issues: ${fetchIssues}\n`; output += `Robots Blocked: ${robotsBlocked}\n`; if (mobileFail > 0 || fetchIssues > 0) { output += `\nRECOMMENDATIONS\n`; output += `${'-'.repeat(60)}\n`; if (mobileFail > 0) { output += `1. Fix mobile usability issues on ${mobileFail} page(s)\n`; } if (fetchIssues > 0) { output += `2. Investigate fetch issues on ${fetchIssues} page(s)\n`; } output += `3. For Core Web Vitals data, use PageSpeed Insights\n`; } return { content: [{ type: "text", text: output }] }; } catch (error: any) { return { content: [{ type: "text", text: `Error checking page experience: ${error.message}` }] }; } } }; /** * Tool 3: Get Coverage Report * Comprehensive indexing and coverage analysis */ export const getCoverageReportTool: Tool = { name: "get_coverage_report", description: "Generate a comprehensive indexing and coverage report by analyzing multiple URLs, aggregating statistics, and identifying patterns in indexing issues.", schema: z.object({ site_url: z.string({ required_error: "Site URL is required" }).min(1).describe("The URL of the site in Search Console"), urls: z.string({ required_error: "URLs are required" }).min(1).describe("List of URLs to analyze, one per line (max 25)") }), async execute(params: ToolParams, args: z.infer<typeof this.schema>) { const { site_url, urls } = args; try { const client = await getAuthenticatedClient(params); if ('error' in client) { return { content: [{ type: "text", text: client.error }] }; } const urlList = urls.split(/[\n,]/).map(u => u.trim()).filter(u => u).slice(0, 25); const issues: { [key: string]: string[] } = { 'Indexed': [], 'Crawled - not indexed': [], 'Discovered - not indexed': [], 'Blocked by robots.txt': [], 'Redirect': [], 'Not found (404)': [], 'Server error (5xx)': [], 'Other': [] }; let output = `Comprehensive Coverage Report for ${site_url}\n`; output += `${'='.repeat(60)}\n`; output += `URLs Analyzed: ${urlList.length}\n`; output += `Analysis Date: ${new Date().toISOString().split('T')[0]}\n\n`; for (const url of urlList) { try { const result = await client.inspectUrl(site_url, url); const indexStatus = result.inspectionResult?.indexStatusResult; if (!indexStatus) { issues['Other'].push(url); continue; } const verdict = indexStatus.verdict || 'UNKNOWN'; const coverageState = indexStatus.coverageState || 'UNKNOWN'; const pageFetchState = indexStatus.pageFetchState || 'UNKNOWN'; const robotsTxtState = indexStatus.robotsTxtState || 'ALLOWED'; if (verdict === 'PASS' || coverageState === 'Submitted and indexed') { issues['Indexed'].push(url); } else if (robotsTxtState === 'BLOCKED') { issues['Blocked by robots.txt'].push(url); } else if (pageFetchState === 'NOT_FOUND') { issues['Not found (404)'].push(url); } else if (pageFetchState === 'SERVER_ERROR') { issues['Server error (5xx)'].push(url); } else if (pageFetchState === 'REDIRECT') { issues['Redirect'].push(url); } else if (coverageState?.includes('Crawled')) { issues['Crawled - not indexed'].push(url); } else if (coverageState?.includes('Discovered')) { issues['Discovered - not indexed'].push(url); } else { issues['Other'].push(url); } } catch (urlError: any) { issues['Other'].push(url); } } // Coverage Summary const indexed = issues['Indexed'].length; const notIndexed = urlList.length - indexed; output += `COVERAGE SUMMARY\n`; output += `${'-'.repeat(60)}\n`; output += `Indexed: ${indexed} (${((indexed / urlList.length) * 100).toFixed(0)}%)\n`; output += `Not Indexed: ${notIndexed} (${((notIndexed / urlList.length) * 100).toFixed(0)}%)\n\n`; // Issue Breakdown output += `ISSUE BREAKDOWN\n`; output += `${'-'.repeat(60)}\n`; output += `Issue Type | Count | % | Priority\n`; output += `${'-'.repeat(60)}\n`; const priorityMap: { [key: string]: string } = { 'Crawled - not indexed': 'HIGH', 'Discovered - not indexed': 'MEDIUM', 'Not found (404)': 'HIGH', 'Server error (5xx)': 'CRITICAL', 'Blocked by robots.txt': 'CHECK', 'Redirect': 'LOW', 'Other': 'CHECK' }; for (const [issueType, urlsAffected] of Object.entries(issues)) { if (issueType !== 'Indexed' && urlsAffected.length > 0) { const count = urlsAffected.length; const pct = ((count / urlList.length) * 100).toFixed(0); const priority = priorityMap[issueType] || 'CHECK'; output += `${issueType} | ${count} | ${pct}% | ${priority}\n`; } } // Detailed Issues output += `\nDETAILED ISSUES\n`; output += `${'-'.repeat(60)}\n`; for (const [issueType, urlsAffected] of Object.entries(issues)) { if (issueType !== 'Indexed' && urlsAffected.length > 0) { output += `\n${issueType.toUpperCase()} (${urlsAffected.length} URLs):\n`; urlsAffected.slice(0, 10).forEach(url => { output += ` - ${url}\n`; }); if (urlsAffected.length > 10) { output += ` ... and ${urlsAffected.length - 10} more\n`; } } } // Action Items output += `\nACTION ITEMS\n`; output += `${'-'.repeat(60)}\n`; let actionNum = 1; if (issues['Server error (5xx)'].length > 0) { output += `${actionNum++}. [CRITICAL] Fix server errors on ${issues['Server error (5xx)'].length} page(s)\n`; } if (issues['Crawled - not indexed'].length > 0) { output += `${actionNum++}. [HIGH] Review ${issues['Crawled - not indexed'].length} crawled-but-not-indexed pages for quality\n`; } if (issues['Not found (404)'].length > 0) { output += `${actionNum++}. [HIGH] Fix or redirect ${issues['Not found (404)'].length} 404 pages\n`; } if (issues['Discovered - not indexed'].length > 0) { output += `${actionNum++}. [MEDIUM] Add internal links to ${issues['Discovered - not indexed'].length} discovered-but-not-indexed pages\n`; } if (issues['Blocked by robots.txt'].length > 0) { output += `${actionNum++}. [CHECK] Verify robots.txt blocks on ${issues['Blocked by robots.txt'].length} pages are intentional\n`; } return { content: [{ type: "text", text: output }] }; } catch (error: any) { return { content: [{ type: "text", text: `Error generating coverage report: ${error.message}` }] }; } } }; /** * Tool 4: Analyze Backlinks (Limited) * Page authority analysis - note: external backlinks NOT available via API */ export const analyzeBacklinksTool: Tool = { name: "analyze_backlinks", description: "Analyze page authority and internal linking patterns based on search visibility. IMPORTANT: External backlink data is NOT available via GSC API - use GSC Web UI or third-party tools (Ahrefs, SEMrush) for external link data.", schema: z.object({ site_url: z.string({ required_error: "Site URL is required" }).min(1).describe("The URL of the site in Search Console"), days: z.number() .min(1).max(540) .default(28) .describe("Number of days to analyze (default: 28)"), min_impressions: z.number() .min(0) .default(10) .describe("Minimum impressions for low-visibility detection (default: 10)") }), async execute(params: ToolParams, args: z.infer<typeof this.schema>) { const { site_url, days, min_impressions } = args; try { const client = await getAuthenticatedClient(params); if ('error' in client) { return { content: [{ type: "text", text: client.error }] }; } const { startDate, endDate } = getDateRange(days); // Query pages with their metrics const pageData = await client.querySearchAnalytics(site_url, { startDate, endDate, dimensions: ['page'], rowLimit: 1000 }); // Query page + query to find hub pages const pageQueryData = await client.querySearchAnalytics(site_url, { startDate, endDate, dimensions: ['page', 'query'], rowLimit: 5000 }); let output = `Link & Page Authority Analysis for ${site_url}\n`; output += `${'='.repeat(60)}\n`; output += `IMPORTANT: External backlink data is NOT available via GSC API.\n`; output += `For external links, use GSC Web UI or third-party tools.\n`; output += `\nThis report analyzes internal page authority based on search visibility.\n`; output += `Analysis Period: Last ${days} days\n\n`; if (!pageData.rows || pageData.rows.length === 0) { return { content: [{ type: "text", text: output + `No page data available for ${site_url}.` }] }; } // Calculate authority score (based on clicks, impressions, position) const pagesWithAuthority = pageData.rows.map((row: any) => { const clicks = row.clicks || 0; const impressions = row.impressions || 0; const position = row.position || 100; // Simple authority score: weighted combination const authorityScore = Math.round( (clicks * 2) + (impressions * 0.1) + ((100 - Math.min(position, 100)) * 0.5) ); return { page: row.keys[0], clicks, impressions, position, authorityScore }; }).sort((a: any, b: any) => b.authorityScore - a.authorityScore); // Count queries per page (hub pages) const queriesPerPage: { [key: string]: number } = {}; if (pageQueryData.rows) { pageQueryData.rows.forEach((row: any) => { const page = row.keys[0]; queriesPerPage[page] = (queriesPerPage[page] || 0) + 1; }); } // Top authority pages output += `TOP AUTHORITY PAGES (by search visibility)\n`; output += `${'-'.repeat(60)}\n`; output += `Page | Clicks | Impressions | Queries | Score\n`; output += `${'-'.repeat(60)}\n`; pagesWithAuthority.slice(0, 15).forEach((page: any) => { const pagePath = page.page.replace(site_url, '').substring(0, 40) || '/'; const queries = queriesPerPage[page.page] || 0; output += `${pagePath} | ${page.clicks} | ${page.impressions} | ${queries} | ${page.authorityScore}\n`; }); // Hub pages (many queries) output += `\nHUB PAGES (rank for many queries)\n`; output += `${'-'.repeat(60)}\n`; output += `Good candidates for internal link sources:\n`; const hubPages = Object.entries(queriesPerPage) .sort(([, a], [, b]) => b - a) .slice(0, 10); hubPages.forEach(([page, queryCount], i) => { const pagePath = page.replace(site_url, '').substring(0, 50) || '/'; output += `${i + 1}. ${pagePath} - Ranks for ${queryCount} queries\n`; }); // Low visibility pages const lowVisibility = pagesWithAuthority.filter((p: any) => p.impressions < min_impressions && p.impressions > 0); output += `\nLOW VISIBILITY PAGES (potential orphans)\n`; output += `${'-'.repeat(60)}\n`; output += `These pages have few impressions - may need more internal links:\n`; lowVisibility.slice(0, 10).forEach((page: any, i: number) => { const pagePath = page.page.replace(site_url, '').substring(0, 50) || '/'; output += `${i + 1}. ${pagePath} - ${page.impressions} impressions, ${page.clicks} clicks\n`; }); // Recommendations output += `\nINTERNAL LINKING RECOMMENDATIONS\n`; output += `${'-'.repeat(60)}\n`; output += `1. Add links FROM hub pages TO low-visibility pages\n`; output += `2. Create topic clusters around top authority pages\n`; output += `3. Review orphan pages for proper internal linking\n`; output += `\nFOR EXTERNAL BACKLINK DATA\n`; output += `${'-'.repeat(60)}\n`; output += `- GSC Web UI: Search Console > Links\n`; output += `- Ahrefs: https://ahrefs.com/\n`; output += `- SEMrush: https://www.semrush.com/\n`; output += `- Moz: https://moz.com/link-explorer\n`; return { content: [{ type: "text", text: output }] }; } catch (error: any) { return { content: [{ type: "text", text: `Error analyzing backlinks: ${error.message}` }] }; } } }; /** * Tool 5: Spot Content Opportunities * Find rising/declining queries and content refresh candidates */ export const spotContentOpportunitiesTool: Tool = { name: "spot_content_opportunities", description: "Compare recent period vs previous period to find rising queries (emerging opportunities), declining pages (content refresh candidates), and new/lost queries.", schema: z.object({ site_url: z.string({ required_error: "Site URL is required" }).min(1).describe("The URL of the site in Search Console"), recent_days: z.number() .min(7).max(90) .default(14) .describe("Recent period in days (default: 14)"), comparison_days: z.number() .min(7).max(90) .default(14) .describe("Comparison period length - immediately before recent (default: 14)"), min_impressions: z.number() .min(1) .default(50) .describe("Minimum impressions to include (default: 50)"), growth_threshold: z.number() .min(1) .default(20) .describe("Percentage growth to flag as 'rising' (default: 20%)") }), async execute(params: ToolParams, args: z.infer<typeof this.schema>) { const { site_url, recent_days, comparison_days, min_impressions, growth_threshold } = args; try { const client = await getAuthenticatedClient(params); if ('error' in client) { return { content: [{ type: "text", text: client.error }] }; } // Calculate date ranges const recentEnd = new Date(); const recentStart = new Date(); recentStart.setDate(recentStart.getDate() - recent_days); const compEnd = new Date(recentStart); compEnd.setDate(compEnd.getDate() - 1); const compStart = new Date(compEnd); compStart.setDate(compStart.getDate() - comparison_days); // Query recent period (queries) const recentQueries = await client.querySearchAnalytics(site_url, { startDate: formatDate(recentStart), endDate: formatDate(recentEnd), dimensions: ['query'], rowLimit: 2000 }); // Query comparison period (queries) const compQueries = await client.querySearchAnalytics(site_url, { startDate: formatDate(compStart), endDate: formatDate(compEnd), dimensions: ['query'], rowLimit: 2000 }); // Query recent period (pages) const recentPages = await client.querySearchAnalytics(site_url, { startDate: formatDate(recentStart), endDate: formatDate(recentEnd), dimensions: ['page'], rowLimit: 1000 }); // Query comparison period (pages) const compPages = await client.querySearchAnalytics(site_url, { startDate: formatDate(compStart), endDate: formatDate(compEnd), dimensions: ['page'], rowLimit: 1000 }); // Build maps for comparison const recentQueryMap = new Map(); const compQueryMap = new Map(); const recentPageMap = new Map(); const compPageMap = new Map(); (recentQueries.rows || []).forEach((row: any) => { recentQueryMap.set(row.keys[0], row); }); (compQueries.rows || []).forEach((row: any) => { compQueryMap.set(row.keys[0], row); }); (recentPages.rows || []).forEach((row: any) => { recentPageMap.set(row.keys[0], row); }); (compPages.rows || []).forEach((row: any) => { compPageMap.set(row.keys[0], row); }); let output = `Content Opportunities Report for ${site_url}\n`; output += `${'='.repeat(60)}\n`; output += `Recent Period: ${formatDate(recentStart)} to ${formatDate(recentEnd)}\n`; output += `Comparison Period: ${formatDate(compStart)} to ${formatDate(compEnd)}\n\n`; // Rising queries const risingQueries: any[] = []; const decliningQueries: any[] = []; const newQueries: any[] = []; const lostQueries: any[] = []; recentQueryMap.forEach((recent, query) => { const comp = compQueryMap.get(query); if (recent.impressions >= min_impressions) { if (!comp) { newQueries.push({ query, ...recent }); } else { const growth = calculatePercentageChange(comp.clicks, recent.clicks); if (growth >= growth_threshold) { risingQueries.push({ query, recent, comp, growth }); } else if (growth <= -growth_threshold) { decliningQueries.push({ query, recent, comp, growth }); } } } }); compQueryMap.forEach((comp, query) => { if (!recentQueryMap.has(query) && comp.impressions >= min_impressions) { lostQueries.push({ query, ...comp }); } }); // Sort risingQueries.sort((a, b) => b.growth - a.growth); decliningQueries.sort((a, b) => a.growth - b.growth); newQueries.sort((a, b) => b.clicks - a.clicks); // Emerging Topics output += `EMERGING TOPICS (Rising Queries)\n`; output += `${'-'.repeat(60)}\n`; output += `These queries are growing fast - create or expand content!\n\n`; if (risingQueries.length > 0) { output += `Query | Recent Clicks | Growth | Impressions\n`; output += `${'-'.repeat(60)}\n`; risingQueries.slice(0, 15).forEach((item: any) => { const query = item.query.substring(0, 35); output += `${query} | ${item.recent.clicks} | ${formatChange(item.growth)}% | ${item.recent.impressions}\n`; }); } else { output += `No significantly rising queries found.\n`; } // New Queries output += `\nNEW QUERIES (First appearance)\n`; output += `${'-'.repeat(60)}\n`; if (newQueries.length > 0) { output += `Query | Clicks | Impressions | Position\n`; output += `${'-'.repeat(60)}\n`; newQueries.slice(0, 10).forEach((item: any) => { const query = item.query.substring(0, 35); output += `${query} | ${item.clicks} | ${item.impressions} | ${item.position?.toFixed(1) || 'N/A'}\n`; }); } else { output += `No new queries found.\n`; } // Declining Topics output += `\nDECLINING TOPICS (Refresh Needed)\n`; output += `${'-'.repeat(60)}\n`; if (decliningQueries.length > 0) { output += `Query | Recent Clicks | Decline | Action\n`; output += `${'-'.repeat(60)}\n`; decliningQueries.slice(0, 15).forEach((item: any) => { const query = item.query.substring(0, 30); const action = item.growth < -50 ? 'URGENT' : 'Review'; output += `${query} | ${item.recent.clicks} | ${formatChange(item.growth)}% | ${action}\n`; }); } else { output += `No significantly declining queries found.\n`; } // Declining Pages output += `\nPAGES NEEDING REFRESH\n`; output += `${'-'.repeat(60)}\n`; const decliningPages: any[] = []; recentPageMap.forEach((recent, page) => { const comp = compPageMap.get(page); if (comp && recent.clicks >= 5) { const decline = calculatePercentageChange(comp.clicks, recent.clicks); if (decline <= -growth_threshold) { decliningPages.push({ page, recent, comp, decline }); } } }); decliningPages.sort((a, b) => a.decline - b.decline); if (decliningPages.length > 0) { decliningPages.slice(0, 10).forEach((item: any) => { const pagePath = item.page.replace(site_url, '').substring(0, 45) || '/'; output += `${pagePath}\n Clicks: ${item.comp.clicks} -> ${item.recent.clicks} (${formatChange(item.decline)}%)\n`; }); } else { output += `No significantly declining pages found.\n`; } // Recommendations output += `\nCONTENT RECOMMENDATIONS\n`; output += `${'-'.repeat(60)}\n`; let recNum = 1; if (risingQueries.length > 0) { output += `${recNum++}. [HIGH] Create/expand content for "${risingQueries[0].query}"\n`; } if (decliningPages.length > 0) { output += `${recNum++}. [HIGH] Refresh content on declining pages\n`; } if (newQueries.length > 0) { output += `${recNum++}. [MEDIUM] Optimize for new queries appearing\n`; } if (lostQueries.length > 0) { output += `${recNum++}. [LOW] Investigate ${lostQueries.length} lost queries\n`; } return { content: [{ type: "text", text: output }] }; } catch (error: any) { return { content: [{ type: "text", text: `Error spotting content opportunities: ${error.message}` }] }; } } }; /** * Tool 6: Analyze Regional & Device Performance * Country and device breakdown with gap analysis */ export const analyzeRegionalDevicePerformanceTool: Tool = { name: "analyze_regional_device_performance", description: "Analyze search performance breakdown by country and device type. Identify mobile vs desktop gaps, top-performing regions, and opportunities for geo-targeted optimization.", schema: z.object({ site_url: z.string({ required_error: "Site URL is required" }).min(1).describe("The URL of the site in Search Console"), days: z.number() .min(1).max(540) .default(28) .describe("Number of days to analyze (default: 28)"), top_countries: z.number() .min(1).max(50) .default(10) .describe("Number of top countries to show (default: 10)") }), async execute(params: ToolParams, args: z.infer<typeof this.schema>) { const { site_url, days, top_countries } = args; try { const client = await getAuthenticatedClient(params); if ('error' in client) { return { content: [{ type: "text", text: client.error }] }; } const { startDate, endDate } = getDateRange(days); // Query by device const deviceData = await client.querySearchAnalytics(site_url, { startDate, endDate, dimensions: ['device'], rowLimit: 100 }); // Query by country const countryData = await client.querySearchAnalytics(site_url, { startDate, endDate, dimensions: ['country'], rowLimit: 250 }); // Query by country + device for cross-analysis const countryDeviceData = await client.querySearchAnalytics(site_url, { startDate, endDate, dimensions: ['country', 'device'], rowLimit: 500 }); let output = `Regional & Device Performance Report for ${site_url}\n`; output += `${'='.repeat(60)}\n`; output += `Analysis Period: Last ${days} days (${startDate} to ${endDate})\n\n`; // Device Breakdown output += `DEVICE BREAKDOWN\n`; output += `${'-'.repeat(60)}\n`; output += `Device | Clicks | Impressions | CTR | Avg Position\n`; output += `${'-'.repeat(60)}\n`; const devices: { [key: string]: any } = {}; let totalClicks = 0; let totalImpressions = 0; (deviceData.rows || []).forEach((row: any) => { const device = row.keys[0]; devices[device] = row; totalClicks += row.clicks || 0; totalImpressions += row.impressions || 0; output += `${device} | ${row.clicks} | ${row.impressions} | ${(row.ctr * 100).toFixed(2)}% | ${row.position?.toFixed(1) || 'N/A'}\n`; }); // Mobile vs Desktop Gap Analysis if (devices['MOBILE'] && devices['DESKTOP']) { const mobile = devices['MOBILE']; const desktop = devices['DESKTOP']; output += `\nMobile vs Desktop Gap Analysis:\n`; const mobileShare = ((mobile.impressions / totalImpressions) * 100).toFixed(0); const mobileClickShare = ((mobile.clicks / totalClicks) * 100).toFixed(0); output += `- Mobile gets ${mobileShare}% of impressions and ${mobileClickShare}% of clicks\n`; const ctrGap = (mobile.ctr - desktop.ctr) * 100; output += `- Mobile CTR is ${formatChange(ctrGap, 2)}pp vs Desktop\n`; const posGap = desktop.position - mobile.position; output += `- Desktop position is ${formatChange(posGap, 1)} vs Mobile\n`; if (mobile.ctr < desktop.ctr * 0.8) { output += `- [ALERT] Mobile CTR significantly lower than desktop\n`; } } // Country Breakdown output += `\nTOP COUNTRIES BY TRAFFIC\n`; output += `${'-'.repeat(60)}\n`; output += `Country | Clicks | Impressions | CTR | Position | Opportunity\n`; output += `${'-'.repeat(60)}\n`; const countries = (countryData.rows || []) .sort((a: any, b: any) => b.clicks - a.clicks) .slice(0, top_countries); const avgCTR = totalClicks / totalImpressions; countries.forEach((row: any) => { const country = row.keys[0]; let opportunity = ''; if (row.ctr < avgCTR * 0.5) { opportunity = 'CTR LOW'; } else if (row.position > 15) { opportunity = 'Position HIGH'; } else if (row.impressions > 10000 && row.ctr < avgCTR * 0.7) { opportunity = 'CTR OPPORTUNITY'; } output += `${country} | ${row.clicks} | ${row.impressions} | ${(row.ctr * 100).toFixed(2)}% | ${row.position?.toFixed(1)} | ${opportunity}\n`; }); // Country + Device Insights output += `\nCOUNTRY + DEVICE INSIGHTS\n`; output += `${'-'.repeat(60)}\n`; // Group by country const countryDeviceMap: { [key: string]: { [key: string]: any } } = {}; (countryDeviceData.rows || []).forEach((row: any) => { const country = row.keys[0]; const device = row.keys[1]; if (!countryDeviceMap[country]) { countryDeviceMap[country] = {}; } countryDeviceMap[country][device] = row; }); // Find countries with mobile gaps const topCountryCodes = countries.slice(0, 5).map((c: any) => c.keys[0]); topCountryCodes.forEach((country: string) => { const deviceBreakdown = countryDeviceMap[country]; if (deviceBreakdown && deviceBreakdown['MOBILE'] && deviceBreakdown['DESKTOP']) { const mobile = deviceBreakdown['MOBILE']; const desktop = deviceBreakdown['DESKTOP']; if (mobile.ctr < desktop.ctr * 0.7) { output += `${country}:\n`; output += ` Desktop: CTR ${(desktop.ctr * 100).toFixed(2)}%, Position ${desktop.position?.toFixed(1)}\n`; output += ` Mobile: CTR ${(mobile.ctr * 100).toFixed(2)}%, Position ${mobile.position?.toFixed(1)}\n`; output += ` [GAP: Mobile underperforming]\n\n`; } } }); // Recommendations output += `RECOMMENDATIONS\n`; output += `${'-'.repeat(60)}\n`; let recNum = 1; if (devices['MOBILE'] && devices['DESKTOP'] && devices['MOBILE'].ctr < devices['DESKTOP'].ctr * 0.8) { output += `${recNum++}. [HIGH] Investigate mobile performance issues globally\n`; } const lowCTRCountries = countries.filter((c: any) => c.ctr < avgCTR * 0.5 && c.impressions > 1000); if (lowCTRCountries.length > 0) { output += `${recNum++}. [MEDIUM] Improve CTR in ${lowCTRCountries.map((c: any) => c.keys[0]).join(', ')}\n`; } const highPosCountries = countries.filter((c: any) => c.position > 15 && c.impressions > 5000); if (highPosCountries.length > 0) { output += `${recNum++}. [MEDIUM] Target content optimization for ${highPosCountries.map((c: any) => c.keys[0]).join(', ')}\n`; } return { content: [{ type: "text", text: output }] }; } catch (error: any) { return { content: [{ type: "text", text: `Error analyzing regional/device performance: ${error.message}` }] }; } } }; /** * Tool 7: Analyze Algorithm Impact * Before/after comparison for algorithm updates */ export const analyzeAlgorithmImpactTool: Tool = { name: "analyze_algorithm_impact", description: "Compare search performance before and after a specific date (algorithm update, site change, etc.) to identify affected pages and queries. Helps diagnose whether changes are due to algorithm updates or site issues.", schema: z.object({ site_url: z.string({ required_error: "Site URL is required" }).min(1).describe("The URL of the site in Search Console"), event_date: z.string({ required_error: "Event date is required" }).regex(/^\d{4}-\d{2}-\d{2}$/, { message: "Date must be in YYYY-MM-DD format" }) .describe("The date of the event/update (YYYY-MM-DD)"), days_before: z.number() .min(7).max(90) .default(14) .describe("Days to analyze before the event (default: 14)"), days_after: z.number() .min(7).max(90) .default(14) .describe("Days to analyze after the event (default: 14)"), min_impressions: z.number() .min(1) .default(50) .describe("Minimum impressions to include (default: 50)") }), async execute(params: ToolParams, args: z.infer<typeof this.schema>) { const { site_url, event_date, days_before, days_after, min_impressions } = args; try { const client = await getAuthenticatedClient(params); if ('error' in client) { return { content: [{ type: "text", text: client.error }] }; } const eventDateObj = new Date(event_date); // Before period const beforeEnd = new Date(eventDateObj); beforeEnd.setDate(beforeEnd.getDate() - 1); const beforeStart = new Date(beforeEnd); beforeStart.setDate(beforeStart.getDate() - days_before); // After period const afterStart = new Date(eventDateObj); const afterEnd = new Date(afterStart); afterEnd.setDate(afterEnd.getDate() + days_after); // Query before/after for queries const beforeQueries = await client.querySearchAnalytics(site_url, { startDate: formatDate(beforeStart), endDate: formatDate(beforeEnd), dimensions: ['query'], rowLimit: 2000 }); const afterQueries = await client.querySearchAnalytics(site_url, { startDate: formatDate(afterStart), endDate: formatDate(afterEnd), dimensions: ['query'], rowLimit: 2000 }); // Query before/after for pages const beforePages = await client.querySearchAnalytics(site_url, { startDate: formatDate(beforeStart), endDate: formatDate(beforeEnd), dimensions: ['page'], rowLimit: 1000 }); const afterPages = await client.querySearchAnalytics(site_url, { startDate: formatDate(afterStart), endDate: formatDate(afterEnd), dimensions: ['page'], rowLimit: 1000 }); // Query totals const beforeTotals = await client.querySearchAnalytics(site_url, { startDate: formatDate(beforeStart), endDate: formatDate(beforeEnd), dimensions: [] }); const afterTotals = await client.querySearchAnalytics(site_url, { startDate: formatDate(afterStart), endDate: formatDate(afterEnd), dimensions: [] }); let output = `Algorithm/Update Impact Analysis for ${site_url}\n`; output += `${'='.repeat(60)}\n`; output += `Event Date: ${event_date}\n`; output += `Before Period: ${formatDate(beforeStart)} to ${formatDate(beforeEnd)}\n`; output += `After Period: ${formatDate(afterStart)} to ${formatDate(afterEnd)}\n\n`; // Overall Impact output += `OVERALL IMPACT\n`; output += `${'-'.repeat(60)}\n`; output += `Metric | Before | After | Change\n`; output += `${'-'.repeat(60)}\n`; const before = beforeTotals.rows?.[0] || { clicks: 0, impressions: 0, ctr: 0, position: 0 }; const after = afterTotals.rows?.[0] || { clicks: 0, impressions: 0, ctr: 0, position: 0 }; const clicksChange = calculatePercentageChange(before.clicks, after.clicks); const impressionsChange = calculatePercentageChange(before.impressions, after.impressions); const ctrChange = (after.ctr - before.ctr) * 100; const positionChange = before.position - after.position; output += `Total Clicks | ${before.clicks} | ${after.clicks} | ${formatChange(clicksChange)}%\n`; output += `Total Impressions | ${before.impressions} | ${after.impressions} | ${formatChange(impressionsChange)}%\n`; output += `Average CTR | ${(before.ctr * 100).toFixed(2)}% | ${(after.ctr * 100).toFixed(2)}% | ${formatChange(ctrChange, 2)}pp\n`; output += `Average Position | ${before.position?.toFixed(1)} | ${after.position?.toFixed(1)} | ${formatChange(positionChange, 1)}\n`; // Impact Assessment let impactLevel = 'NEUTRAL'; if (clicksChange < -30) impactLevel = 'SEVERE NEGATIVE'; else if (clicksChange < -15) impactLevel = 'MODERATE NEGATIVE'; else if (clicksChange < -5) impactLevel = 'MINOR NEGATIVE'; else if (clicksChange > 30) impactLevel = 'STRONG POSITIVE'; else if (clicksChange > 15) impactLevel = 'MODERATE POSITIVE'; else if (clicksChange > 5) impactLevel = 'MINOR POSITIVE'; output += `\nImpact Assessment: ${impactLevel}\n`; // Build maps const beforeQueryMap = new Map(); const afterQueryMap = new Map(); const beforePageMap = new Map(); const afterPageMap = new Map(); (beforeQueries.rows || []).forEach((row: any) => beforeQueryMap.set(row.keys[0], row)); (afterQueries.rows || []).forEach((row: any) => afterQueryMap.set(row.keys[0], row)); (beforePages.rows || []).forEach((row: any) => beforePageMap.set(row.keys[0], row)); (afterPages.rows || []).forEach((row: any) => afterPageMap.set(row.keys[0], row)); // Find losers and winners (queries) const queryChanges: any[] = []; afterQueryMap.forEach((afterRow, query) => { const beforeRow = beforeQueryMap.get(query); if (beforeRow && beforeRow.impressions >= min_impressions) { const change = calculatePercentageChange(beforeRow.clicks, afterRow.clicks); const posChange = beforeRow.position - afterRow.position; queryChanges.push({ query, before: beforeRow, after: afterRow, change, posChange }); } }); const queryLosers = queryChanges.filter(q => q.change < -20).sort((a, b) => a.change - b.change); const queryWinners = queryChanges.filter(q => q.change > 20).sort((a, b) => b.change - a.change); // Query Losers output += `\nMOST AFFECTED QUERIES (Losers)\n`; output += `${'-'.repeat(60)}\n`; if (queryLosers.length > 0) { output += `Query | Before | After | Change | Pos Change\n`; output += `${'-'.repeat(60)}\n`; queryLosers.slice(0, 15).forEach((item: any) => { const query = item.query.substring(0, 30); output += `${query} | ${item.before.clicks} | ${item.after.clicks} | ${formatChange(item.change)}% | ${formatChange(item.posChange, 1)}\n`; }); } else { output += `No significant query losses found.\n`; } // Find losers (pages) const pageChanges: any[] = []; afterPageMap.forEach((afterRow, page) => { const beforeRow = beforePageMap.get(page); if (beforeRow && beforeRow.impressions >= min_impressions) { const change = calculatePercentageChange(beforeRow.clicks, afterRow.clicks); pageChanges.push({ page, before: beforeRow, after: afterRow, change }); } }); const pageLosers = pageChanges.filter(p => p.change < -20).sort((a, b) => a.change - b.change); const pageWinners = pageChanges.filter(p => p.change > 20).sort((a, b) => b.change - a.change); // Page Losers output += `\nMOST AFFECTED PAGES (Losers)\n`; output += `${'-'.repeat(60)}\n`; if (pageLosers.length > 0) { pageLosers.slice(0, 10).forEach((item: any) => { const pagePath = item.page.replace(site_url, '').substring(0, 45) || '/'; output += `${pagePath}\n Clicks: ${item.before.clicks} -> ${item.after.clicks} (${formatChange(item.change)}%)\n`; }); } else { output += `No significant page losses found.\n`; } // Winners output += `\nWINNERS (Improved after event)\n`; output += `${'-'.repeat(60)}\n`; if (queryWinners.length > 0 || pageWinners.length > 0) { output += `Queries:\n`; queryWinners.slice(0, 5).forEach((item: any) => { output += ` "${item.query.substring(0, 35)}" ${formatChange(item.change)}%\n`; }); output += `Pages:\n`; pageWinners.slice(0, 5).forEach((item: any) => { const pagePath = item.page.replace(site_url, '').substring(0, 40) || '/'; output += ` ${pagePath} ${formatChange(item.change)}%\n`; }); } else { output += `No significant winners found.\n`; } // Recovery Recommendations output += `\nRECOVERY RECOMMENDATIONS\n`; output += `${'-'.repeat(60)}\n`; let recNum = 1; if (impactLevel.includes('NEGATIVE')) { if (pageLosers.length > 0) { output += `${recNum++}. [URGENT] Review and update content on affected pages\n`; } if (queryLosers.length > 0) { output += `${recNum++}. [HIGH] Analyze lost queries for content gaps\n`; } output += `${recNum++}. [HIGH] Check E-E-A-T signals on affected pages\n`; output += `${recNum++}. [MEDIUM] Review internal linking to affected pages\n`; } output += `${recNum++}. Monitor for 2 more weeks to confirm trend\n`; output += `${recNum++}. Check Google Search Status Dashboard for announced updates\n`; return { content: [{ type: "text", text: output }] }; } catch (error: any) { return { content: [{ type: "text", text: `Error analyzing algorithm impact: ${error.message}` }] }; } } };

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/hablapro/mcp-gsc'

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