Skip to main content
Glama
acamolese

Google Search Console Audit MCP

gsc_audit

Run a single command to generate a complete HTML SEO audit report from Google Search Console data, including performance analysis, issue detection, and actionable recommendations.

Instructions

Generate a complete HTML SEO audit report for a Search Console property.

Runs multiple queries (overview, previous-period comparison, top queries, top pages, devices, countries, daily trend, sitemaps, indexing check), detects common issues, builds an actionable strategy and renders everything in a self-contained HTML report with Chart.js graphs. The report layout and colors can be customized via branding.json.

IMPORTANT: If the user has not specified a date range, ask them before calling this tool. Do not assume defaults.

Args: site_url: Site URL (e.g. "https://example.com/" or "sc-domain:example.com"). date_from: Start date (YYYY-MM-DD). date_to: End date (YYYY-MM-DD). output_dir: Directory where to save the HTML report. Defaults to ~/gsc-reports/. branding_path: Optional path to a custom branding.json overriding the default one.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
site_urlYes
date_fromYes
date_toYes
output_dirNo
branding_pathNo

Output Schema

TableJSON Schema
NameRequiredDescriptionDefault
resultYes

Implementation Reference

  • MCP tool registration and handler for gsc_audit. Decorated with @mcp.tool() and defines the public API (site_url, date_from, date_to, output_dir, branding_path). Delegates the actual work to generate_audit() in audit.py.
    @mcp.tool()
    def gsc_audit(
        site_url: str,
        date_from: str,
        date_to: str,
        output_dir: str = "",
        branding_path: str = "",
    ) -> str:
        """Generate a complete HTML SEO audit report for a Search Console property.
    
        Runs multiple queries (overview, previous-period comparison, top queries, top pages,
        devices, countries, daily trend, sitemaps, indexing check), detects common issues,
        builds an actionable strategy and renders everything in a self-contained HTML report
        with Chart.js graphs. The report layout and colors can be customized via branding.json.
    
        IMPORTANT: If the user has not specified a date range, ask them before calling this
        tool. Do not assume defaults.
    
        Args:
            site_url: Site URL (e.g. "https://example.com/" or "sc-domain:example.com").
            date_from: Start date (YYYY-MM-DD).
            date_to: End date (YYYY-MM-DD).
            output_dir: Directory where to save the HTML report. Defaults to ~/gsc-reports/.
            branding_path: Optional path to a custom branding.json overriding the default one.
        """
        path = generate_audit(_api_get, _api_post, site_url, date_from, date_to, output_dir, branding_path)
        return json.dumps({"report_path": path, "site_url": site_url, "date_from": date_from, "date_to": date_to})
  • The core orchestration function that collects data, detects issues, builds strategy, generates HTML, and writes the report file.
    def generate_audit(
        api_get,
        api_post,
        site_url: str,
        date_from: str,
        date_to: str,
        output_dir: str = "",
        branding_path: str = "",
    ) -> str:
        data = collect_data(api_get, api_post, site_url, date_from, date_to)
        issues = detect_issues(data)
        strategy = build_strategy(data, issues)
        branding = load_branding(branding_path)
        html_content = render_html(data, issues, strategy, branding)
    
        out_dir = Path(output_dir).expanduser() if output_dir else Path.home() / "gsc-reports"
        out_dir.mkdir(parents=True, exist_ok=True)
        filename = f"{site_slug(site_url)}_{date_from}_{date_to}.html"
        out_path = out_dir / filename
        out_path.write_text(html_content, encoding="utf-8")
        return str(out_path)
  • Data collection helper: queries GSC API for overview, previous period comparison, top queries, top pages, devices, countries, daily trend, sitemaps, and indexing status.
    def collect_data(api_get, api_post, site_url: str, date_from: str, date_to: str) -> dict:
        prev_from, prev_to = previous_period(date_from, date_to)
    
        current = _overview(api_post, site_url, date_from, date_to)
        previous = _overview(api_post, site_url, prev_from, prev_to)
    
        top_queries = _query(api_post, site_url, {
            "startDate": date_from, "endDate": date_to,
            "dimensions": ["query"], "rowLimit": 50,
        })
        top_pages = _query(api_post, site_url, {
            "startDate": date_from, "endDate": date_to,
            "dimensions": ["page"], "rowLimit": 50,
        })
        devices = _query(api_post, site_url, {
            "startDate": date_from, "endDate": date_to,
            "dimensions": ["device"],
        })
        countries = _query(api_post, site_url, {
            "startDate": date_from, "endDate": date_to,
            "dimensions": ["country"], "rowLimit": 15,
        })
        trend = _query(api_post, site_url, {
            "startDate": date_from, "endDate": date_to,
            "dimensions": ["date"], "rowLimit": 1000,
        })
        query_page = _query(api_post, site_url, {
            "startDate": date_from, "endDate": date_to,
            "dimensions": ["query", "page"], "rowLimit": 500,
        })
    
        encoded = urllib.parse.quote(site_url, safe="")
        try:
            sitemaps_data = api_get(f"{BASE}/sites/{encoded}/sitemaps")
            sitemaps = sitemaps_data.get("sitemap", [])
        except Exception:
            sitemaps = []
    
        indexing = []
        for row in top_pages[:10]:
            page_url = row["keys"][0]
            try:
                data = api_post(
                    f"{INSPECT_BASE}/urlInspection/index:inspect",
                    {"inspectionUrl": page_url, "siteUrl": site_url},
                )
                result = data.get("inspectionResult", {}).get("indexStatusResult", {})
                indexing.append({
                    "url": page_url,
                    "verdict": result.get("verdict", "UNKNOWN"),
                    "coverageState": result.get("coverageState", ""),
                })
            except Exception as e:
                indexing.append({"url": page_url, "verdict": "ERROR", "coverageState": str(e)[:100]})
    
        # Flatten row records: copy `clicks`, `impressions`, `ctr`, `position` on top level
        # so templates can access q.clicks without .get()
        def _flatten(rows):
            for r in rows:
                r.setdefault("clicks", 0)
                r.setdefault("impressions", 0)
                r.setdefault("ctr", 0)
                r.setdefault("position", 0)
            return rows
    
        return {
            "site_url": site_url,
            "date_from": date_from,
            "date_to": date_to,
            "prev_from": prev_from,
            "prev_to": prev_to,
            "current": current,
            "previous": previous,
            "top_queries": _flatten(top_queries),
            "top_pages": _flatten(top_pages),
            "devices": _flatten(devices),
            "countries": _flatten(countries),
            "trend": _flatten(trend),
            "query_page": _flatten(query_page),
            "sitemaps": sitemaps,
            "indexing": indexing,
        }
  • Issue detection engine: identifies paginated pages, HTTP/HTTPS mix, multiple hosts, low CTR queries, page-2 opportunities, weak high-visibility pages, sitemap issues, indexing problems, and traffic drops.
    def detect_issues(data: dict) -> list[dict]:
        issues = []
    
        # --- Paginated pages ------------------------------------------------------
        paginated = [p for p in data["top_pages"] if "?p=" in p["keys"][0] or "&p=" in p["keys"][0]]
        if paginated:
            total_clicks = sum(p.get("clicks", 0) for p in paginated)
            examples = [
                {"url": p["keys"][0], "clicks": int(p.get("clicks", 0)), "impressions": int(p.get("impressions", 0)), "position": f"{p.get('position', 0):.1f}"}
                for p in sorted(paginated, key=lambda x: x.get("clicks", 0), reverse=True)[:10]
            ]
            issues.append({
                "severity": "high",
                "category": "Indicizzazione",
                "title": f"{len(paginated)} pagine paginate indicizzate",
                "description": f"Le pagine paginate generano {int(total_clicks)} click complessivi e cannibalizzano le pagine principali della categoria. Google può scegliere canonicale diverso da quello dichiarato.",
                "examples": {"type": "pages", "rows": examples},
                "strategy": [
                    "Aggiungere <link rel=\"canonical\"> su tutte le pagine paginate puntando alla prima pagina della categoria",
                    "Verificare in GSC con URL Inspection che Google rispetti il canonical dichiarato",
                    "In alternativa, valutare infinite scroll con history.pushState che mantiene l'URL principale",
                    "Controllare che le paginate non siano linkate dalla sitemap XML",
                    "Se le pagine 2+ hanno contenuti unici necessari per SEO, considerare una strategia rel=prev/next oppure URL SEO-friendly con titoli differenziati",
                ],
            })
    
        # --- HTTP version ---------------------------------------------------------
        http_pages = [p for p in data["top_pages"] if p["keys"][0].startswith("http://")]
        if http_pages:
            total_clicks = sum(p.get("clicks", 0) for p in http_pages)
            examples = [
                {"url": p["keys"][0], "clicks": int(p.get("clicks", 0)), "impressions": int(p.get("impressions", 0)), "position": f"{p.get('position', 0):.1f}"}
                for p in sorted(http_pages, key=lambda x: x.get("clicks", 0), reverse=True)[:10]
            ]
            issues.append({
                "severity": "high",
                "category": "Tecnico",
                "title": "Versione HTTP ancora indicizzata",
                "description": f"Rilevate {len(http_pages)} pagine HTTP con {int(total_clicks)} click nel periodo. La coesistenza di versione HTTP e HTTPS in indice diluisce i segnali SEO e può generare contenuti duplicati.",
                "examples": {"type": "pages", "rows": examples},
                "strategy": [
                    "Configurare redirect 301 permanente da http:// a https:// a livello server (Apache/Nginx/CDN)",
                    "Verificare che HSTS sia attivo con max-age di almeno un anno",
                    "Aggiornare la sitemap XML con solo URL HTTPS e risottometterla",
                    "Verificare con curl -I che ogni URL HTTP risponda 301 e non 302",
                    "Richiedere la reindicizzazione delle versioni HTTPS tramite URL Inspection",
                ],
            })
    
        # --- Host mix -------------------------------------------------------------
        hostnames = set()
        for p in data["top_pages"]:
            url = p["keys"][0]
            if "://" in url:
                hostnames.add(url.split("://")[1].split("/")[0])
        base_hosts = set(h.replace("www.", "") for h in hostnames)
        if len(hostnames) > len(base_hosts):
            mixed_pages: dict = {}
            for p in data["top_pages"]:
                url = p["keys"][0]
                if "://" in url:
                    host = url.split("://")[1].split("/")[0]
                    mixed_pages[host] = mixed_pages.get(host, 0) + int(p.get("clicks", 0))
            examples_rows = [{"host": h, "clicks": c} for h, c in sorted(mixed_pages.items(), key=lambda x: -x[1])]
            issues.append({
                "severity": "medium",
                "category": "Tecnico",
                "title": "Host multipli indicizzati (www e non-www)",
                "description": f"Rilevati {len(hostnames)} hostname diversi che generano traffico: {', '.join(sorted(hostnames))}. Senza una versione canonica unica, i backlink e i segnali di autorità vengono distribuiti.",
                "examples": {"type": "hosts", "rows": examples_rows},
                "strategy": [
                    "Scegliere una versione canonica (raccomandato: con www) e impostare redirect 301 da tutte le altre",
                    "Aggiornare il canonical tag di ogni pagina verso l'hostname scelto",
                    "Aggiornare tutti i link interni per usare l'hostname canonico",
                    "Verificare la proprietà canonica in Google Search Console e richiedere la reindicizzazione",
                ],
            })
    
        # --- Low CTR on high-volume queries --------------------------------------
        low_ctr = [
            q for q in data["top_queries"]
            if q.get("impressions", 0) >= 2000 and q.get("ctr", 0) < 0.02 and q.get("position", 100) <= 10
        ]
        if low_ctr:
            examples = [
                {"query": q["keys"][0], "impressions": int(q.get("impressions", 0)), "ctr": f"{q.get('ctr', 0) * 100:.2f}%", "position": f"{q.get('position', 0):.1f}"}
                for q in sorted(low_ctr, key=lambda x: x.get("impressions", 0), reverse=True)[:10]
            ]
            issues.append({
                "severity": "medium",
                "category": "Contenuti",
                "title": f"{len(low_ctr)} query con CTR <2% in top 10",
                "description": "Query posizionate in prima pagina ma con click-through rate molto basso: il posizionamento c'è ma lo snippet non convince l'utente. Ottima opportunità a basso sforzo.",
                "examples": {"type": "queries", "rows": examples},
                "strategy": [
                    "Riscrivere title tag inserendo USP differenzianti (prezzo, spedizione gratuita, made in Italy, non-iron)",
                    "Aggiornare meta description con call-to-action chiare e limite 155 caratteri",
                    "Implementare schema markup Product/Review/Offer per ottenere rich snippet (stelle, prezzo)",
                    "Testare varianti di title con date/anno per creare percezione di freschezza",
                    "Evitare title generici tipo \"Categoria | Brand\" e preferire formulazioni orientate al beneficio",
                    "Monitorare CTR settimanale dopo ogni modifica e iterare",
                ],
            })
    
        # --- Page 2 opportunities ------------------------------------------------
        page_two = [q for q in data["top_queries"] if 10 < q.get("position", 0) <= 20 and q.get("impressions", 0) >= 500]
        if page_two:
            examples = [
                {"query": q["keys"][0], "impressions": int(q.get("impressions", 0)), "ctr": f"{q.get('ctr', 0) * 100:.2f}%", "position": f"{q.get('position', 0):.1f}"}
                for q in sorted(page_two, key=lambda x: x.get("impressions", 0), reverse=True)[:10]
            ]
            issues.append({
                "severity": "low",
                "category": "Opportunità",
                "title": f"{len(page_two)} query in posizione 11-20 con volume",
                "description": "Query in seconda pagina di Google che, con una spinta mirata, possono entrare in top 10 e aumentare significativamente il traffico.",
                "examples": {"type": "queries", "rows": examples},
                "strategy": [
                    "Identificare la pagina che Google posiziona per ognuna di queste query",
                    "Arricchire il contenuto della pagina con informazioni mancanti (FAQ, specifiche, comparazioni)",
                    "Aggiungere link interni dalle pagine ad alta autorità verso queste pagine target",
                    "Analizzare i top 10 risultati attuali per capire cosa li rende migliori e colmare il gap",
                    "Verificare che le keyword siano presenti in H1, H2, URL e nei primi 100 parole",
                    "Considerare backlink da publisher di settore se la competizione è alta",
                ],
            })
    
        # --- Weak high-visibility pages ------------------------------------------
        weak_pages = [p for p in data["top_pages"] if p.get("impressions", 0) >= 5000 and p.get("ctr", 0) < 0.015]
        if weak_pages:
            examples = [
                {"url": p["keys"][0], "clicks": int(p.get("clicks", 0)), "impressions": int(p.get("impressions", 0)), "position": f"{p.get('position', 0):.1f}"}
                for p in sorted(weak_pages, key=lambda x: x.get("impressions", 0), reverse=True)[:10]
            ]
            issues.append({
                "severity": "medium",
                "category": "Contenuti",
                "title": f"{len(weak_pages)} pagine ad alta visibilità con CTR <1.5%",
                "description": "Pagine con tante impression ma pochissimi click. Indicano una discrepanza tra snippet mostrato e intento dell'utente, oppure una posizione troppo bassa.",
                "examples": {"type": "pages", "rows": examples},
                "strategy": [
                    "Fare una revisione di title e meta description pagina per pagina",
                    "Verificare che il contenuto above-the-fold risponda all'intento di ricerca",
                    "Controllare Core Web Vitals: un LCP alto può penalizzare i click mobile",
                    "Valutare se la pagina è in cannibalizzazione con altre pagine del sito",
                    "Implementare dati strutturati specifici per il tipo di pagina (Product, Article, FAQ)",
                ],
            })
    
        # --- Sitemap warnings ----------------------------------------------------
        warn_sitemaps = [s for s in data["sitemaps"] if int(s.get("warnings", 0)) > 0 or int(s.get("errors", 0)) > 0]
        if warn_sitemaps:
            total_warnings = sum(int(s.get("warnings", 0)) for s in warn_sitemaps)
            total_errors = sum(int(s.get("errors", 0)) for s in warn_sitemaps)
            examples_rows = [
                {"path": s.get("path", ""), "warnings": int(s.get("warnings", 0)), "errors": int(s.get("errors", 0)), "lastDownloaded": s.get("lastDownloaded", "")[:10]}
                for s in warn_sitemaps
            ]
            issues.append({
                "severity": "medium" if total_errors == 0 else "high",
                "category": "Tecnico",
                "title": f"Sitemap con {total_warnings} warning e {total_errors} errori",
                "description": "Le sitemap segnalate da Search Console presentano problemi: URL irraggiungibili, redirect dentro la sitemap, pagine noindex, URL non canonici.",
                "examples": {"type": "sitemaps", "rows": examples_rows},
                "strategy": [
                    "Aprire Search Console → Sitemap → cliccare su ogni sitemap per leggere il dettaglio dei warning",
                    "Rimuovere dalla sitemap gli URL che restituiscono redirect, 404 o noindex",
                    "Verificare che tutti gli URL in sitemap siano HTTPS, canonici e senza parametri",
                    "Assicurarsi che la sitemap non superi 50MB o 50.000 URL (altrimenti usare sitemap index)",
                    "Risottomettere la sitemap pulita in Search Console",
                    "Automatizzare la generazione della sitemap dal CMS se possibile",
                ],
            })
    
        # --- Indexing issues on top pages ----------------------------------------
        bad_index = [i for i in data["indexing"] if i["verdict"] not in ("PASS", "UNKNOWN")]
        if bad_index:
            examples_rows = [{"url": i["url"], "verdict": i["verdict"], "coverageState": i["coverageState"]} for i in bad_index]
            issues.append({
                "severity": "high",
                "category": "Indicizzazione",
                "title": f"{len(bad_index)} pagine top con problemi di indicizzazione",
                "description": "Pagine tra le più importanti del sito che Google non considera in stato PASS. Ogni pagina qui rappresenta traffico a rischio.",
                "examples": {"type": "indexing", "rows": examples_rows},
                "strategy": [
                    "Per ogni pagina, aprire Search Console → URL Inspection per leggere il dettaglio",
                    "Se coverage è \"Duplicate, Google chose different canonical\": verificare il canonical dichiarato e il contenuto unico",
                    "Se è \"Crawled - currently not indexed\": migliorare qualità e unicità del contenuto",
                    "Se è \"Discovered - currently not indexed\": problema di crawl budget, ridurre il numero di URL di bassa qualità",
                    "Dopo ogni fix richiedere l'indicizzazione tramite il bottone \"Request indexing\"",
                    "Monitorare lo stato settimanalmente nelle prime 2-3 settimane",
                ],
            })
    
        # --- Traffic trend -------------------------------------------------------
        cur_clicks = data["current"]["clicks"]
        prev_clicks = data["previous"]["clicks"]
        cur_imp = data["current"]["impressions"]
        prev_imp = data["previous"]["impressions"]
        if prev_clicks > 0:
            delta_pct = (cur_clicks - prev_clicks) / prev_clicks * 100
            if delta_pct < -10:
                imp_delta = ((cur_imp - prev_imp) / prev_imp * 100) if prev_imp > 0 else 0
                issues.append({
                    "severity": "high",
                    "category": "Performance",
                    "title": f"Calo click {delta_pct:.1f}% vs periodo precedente",
                    "description": f"Click passati da {int(prev_clicks):,} a {int(cur_clicks):,}. Impression variate del {imp_delta:+.1f}%. Necessaria indagine immediata sulle cause.",
                    "examples": {"type": "metric", "rows": [
                        {"metric": "Click", "prev": f"{int(prev_clicks):,}", "curr": f"{int(cur_clicks):,}", "delta": f"{delta_pct:+.1f}%"},
                        {"metric": "Impression", "prev": f"{int(prev_imp):,}", "curr": f"{int(cur_imp):,}", "delta": f"{imp_delta:+.1f}%"},
                    ]},
                    "strategy": [
                        "Confrontare top 20 query e pagine nei due periodi per isolare dove è avvenuto il calo",
                        "Verificare eventuali Google Core Update nelle date del calo",
                        "Controllare modifiche recenti al sito: deploy, redirect, rimozione pagine, cambio template",
                        "Analizzare il log del server per individuare errori 5xx o blocchi del crawler",
                        "Verificare manual action e problemi di sicurezza in Search Console",
                        "Se il calo è su keyword specifiche, analizzare cosa stanno facendo i competitor che ti hanno superato",
                    ],
                })
    
        severity_order = {"high": 0, "medium": 1, "low": 2}
        issues.sort(key=lambda x: severity_order.get(x["severity"], 9))
        return issues
  • Instructions registered with FastMCP that tell the LLM to always use gsc_audit for audit/analysis requests and to ask for a date range before calling it.
    MCP_INSTRUCTIONS = """Google Search Console MCP Server.
    
    When the user asks for an "analysis", "audit", "report", "analisi", "audit" or "report" of a site, ALWAYS use the `gsc_audit` tool to generate a complete HTML report instead of running individual queries manually.
    
    IMPORTANT: Before calling `gsc_audit`, you MUST know the date range. If the user did not explicitly specify a period (e.g. "last 30 days", "march 2026", "from X to Y"), ASK the user which period they want to analyze before proceeding. Do not assume a default period.
    
    After generating the report, tell the user the file path and optionally open it in the browser.
    """
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations provided, so description carries full burden. Details that it runs multiple queries, detects issues, and generates an HTML report. Does not explicitly state it is non-destructive, but the read-only nature is implied by the generation of a report. Customization via branding.json is mentioned.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Description is well-structured with a summary paragraph, an important note, and parameter list. Slightly verbose but each sentence adds value, such as explaining the report's features and customization.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the complexity of the tool (multiple queries, HTML report, customization), the description is comprehensive. It covers input constraints, behavior, output (via output schema existence), and user guidance. No obvious gaps.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Despite 0% schema coverage, the description provides clear parameter semantics for all 5 parameters via an 'Args' section, including default for output_dir. Adds meaning beyond the input schema's type-only information.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Description states 'Generate a complete HTML SEO audit report for a Search Console property' with specific verbs and resource. Lists multiple queries and distinguishes from sibling tools like gsc_performance_overview and gsc_query by aggregating multiple analyses into one report.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicitly instructs to ask user for date range if not specified, preventing default assumptions. Provides clear context for when to use (full audit), but does not explicitly mention alternatives or when not to use.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

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/acamolese/google-search-console-mcp'

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