DEV Community

Cover image for Google Search Console + MCP: 3 ways to give Claude your search data (and how I built one)
Rudolfs Rijkuris
Rudolfs Rijkuris

Posted on

Google Search Console + MCP: 3 ways to give Claude your search data (and how I built one)

Search Console is the most useful SEO data most developers never open. It tells you which queries show your pages, how often people click and where you rank. Turning that into "fix these three things this week" is tedious table work, which is exactly what agents are good at.

The missing piece is access. Google doesn't ship an official Search Console MCP server, so you have to wire one up. This post covers:

  1. What the API gives you (and its quirks)
  2. Three ways to expose it over MCP
  3. How I built the hosted one in my product, and the bug I shipped in it
  4. Prompts that turn the data into work

Disclosure: route C below is my product, Blogizi. Routes A and B don't involve it.

1. The API in two minutes

Nearly everything useful comes from one endpoint, searchanalytics.query:

import { google } from "googleapis";

const searchconsole = google.searchconsole({ version: "v1", auth: oauthClient });

const res = await searchconsole.searchanalytics.query({
  siteUrl: "sc-domain:example.com", // or "https://example.com/"
  requestBody: {
    startDate: "2026-09-11",
    endDate: "2026-10-08",
    dimensions: ["query"],          // "page", "date", "country", "device"...
    rowLimit: 1000,                 // max 25,000 per request
    dataState: "final",
  },
});

// res.data.rows: [{ keys: ["yaml frontmatter"], clicks: 4, impressions: 183, ctr: 0.0219, position: 8.4 }, ...]
Enter fullscreen mode Exit fullscreen mode

The quirks that matter for agents:

  • Two property formats. Domain properties are sc-domain:example.com. URL-prefix properties are https://example.com/, with the trailing slash. Normalize both or you'll get 403s that look like auth bugs.
  • Data lags about 2–3 days. If the agent compares "last 7 days" without accounting for that, it will tell you traffic is collapsing.
  • Rows are sorted by clicks. Keep this in mind. It's the bug in section 3.
  • Position is an average, weighted by impressions. To aggregate across rows, weight by impressions; don't average the averages.
  • The scope you want is webmasters.readonly. An agent reading your data has no reason to hold write access to your properties.

2. Three ways to expose it over MCP

A. Self-hosted open-source server

There are several on GitHub. The setup is roughly the same for all of them:

  1. Create a Google Cloud project.
  2. Enable the Search Console API.
  3. Create an OAuth client, or a service account. With a service account, add its email as a user on the property in Search Console.
  4. Point the server at the credentials JSON and register it with your client:
claude mcp add gsc -- npx some-gsc-mcp-server --credentials ./service-account.json
Enter fullscreen mode Exit fullscreen mode

Good: free, full API access (URL inspection, any dimension, many sites), and your data stays on your machine.
Less good: about 20 minutes of Cloud Console setup. It runs as a local process, so it won't work in hosted agents that can't spawn one.

B. Hosted data connectors

Several marketing-data platforms offer a hosted MCP endpoint that already handles the Google OAuth, often bundled with GA4 and ads data. They're paid and multi-source, which makes them good for agencies.

C. Built into the publishing platform

This is the one I built. The reasoning: an SEO agent's loop is read performance → decide what to change → change the post. If reading and writing live on different MCP servers, the agent has to stitch identities together (which GSC page is which post?). If they live on the same server, get_search_performance and update_post share a project and URL space.

claude mcp add --transport http blogizi https://blogizi.com/api/mcp \
  --header "Authorization: Bearer $BLOGIZI_API_KEY"
Enter fullscreen mode Exit fullscreen mode

You click "Connect Google" in the dashboard once and pick the property. No Cloud project. Next, how it works.

3. Building the hosted version

OAuth, once per user

const GSC_SCOPES = [
  "https://www.googleapis.com/auth/webmasters.readonly",
  "openid",
  "email",
];

client.generateAuthUrl({
  access_type: "offline", // we need a refresh token
  prompt: "consent",      // without this, Google only returns it on the first consent
  scope: GSC_SCOPES,
  state,
});
Enter fullscreen mode Exit fullscreen mode

The refresh token is encrypted at rest. If Google doesn't return one (the user consented before), the callback tells them to remove the app from their Google account permissions and retry. Users will hit this, so the error message has to say exactly that.

Cache, don't proxy

The MCP tool does not call Google live. A daily cron syncs each bound property into snapshots, and the tool reads those:

{ "crons": [{ "path": "/api/cron/gsc-sync", "schedule": "0 6 * * *" }] }
Enter fullscreen mode Exit fullscreen mode

The sync runs date, query and page dimensions in parallel, for 7-, 28- and 90-day windows. A manual refresh has a 5-minute cooldown.

Why cache:

  • Agents call tools a lot. One "analyze my SEO" session might call get_search_performance 5–10 times with different windows. That shouldn't be 30 Google API calls.
  • Latency. Snapshot reads are milliseconds; the live API is not.
  • The data is 2+ days old anyway. Real-time buys nothing.

The tool itself

server.registerTool(
  "get_search_performance",
  {
    title: "Get search performance",
    description:
      "Read cached Google Search Console performance for a Blogizi project (clicks, impressions, CTR, position, top queries/pages).",
    inputSchema: z.object({
      projectSlug: z.string().optional(),
      days: z.union([z.literal(7), z.literal(28), z.literal(90)]).optional(),
    }),
  },
  async (args, ctx) => {
    const project = await resolveProjectFromAuth(ctx.http?.authInfo, args.projectSlug);
    const data = await getProjectSearchConsoleSummary(project, args.days ?? 28);
    return jsonResult({
      property: data.property,
      range: data.range,
      lastSyncedAt: data.lastSyncedAt,
      totals: data.totals,
      queries: data.queries, // top 25
      pages: data.pages,     // top 25
    });
  }
);
Enter fullscreen mode Exit fullscreen mode

Design notes:

  • days is a union of literals, not a number. Agents will happily ask for 30 or 45 days. A literal union makes the schema say exactly which windows exist, so the model picks one instead of getting an error.
  • Return range and lastSyncedAt. The agent can then say "data through Oct 8" instead of implying it's live.
  • 25 rows, not 1,000. Tool output goes into the context window. 25 queries plus 25 pages is enough to find the near-wins without burying the model.

The bug I shipped

While taking screenshots for an article, I noticed my own dashboard's top-queries list was alphabetical: "astro app", "nanoclaw", "surfer seo"... The query responsible for a quarter of my impressions wasn't in it.

The sync requested rowLimit: 50 and stored the rows in API order. The API sorts by clicks. On a young site nearly every query has 0 clicks, so everything ties, and the tie order I got was alphabetical. The cut to 50 (and then 25) kept the alphabetically-first queries instead of the most-seen ones. Worse, this is what the MCP tool returns, so agents were analyzing an alphabetical sample.

The fix is to fetch wide and sort on what you actually care about:

const rows = mapApiRows(res.data.rows) // requested with rowLimit: 1000
  .sort((a, b) => b.clicks - a.clicks || b.impressions - a.impressions)
  .slice(0, 50);
Enter fullscreen mode Exit fullscreen mode

General lesson for anyone wrapping an API in an MCP tool: truncation is a product decision. Whatever you cut is invisible to the model, and it won't know to ask for it.

4. Prompts that make it worth it

These work with any of the three routes.

Titles that aren't earning clicks

Get my search performance for the last 28 days. Which pages have 200+ impressions but a CTR clearly below pages at a similar position? For each, suggest a new title and meta description based on its top query.

Almost on page one

Which queries do I rank 8–20 for? For each: which page ranks, what's missing compared to what the searcher wants, and which of my other posts should link to it?

Demand you're not serving

List queries where the ranking page is my homepage or a loosely related post. Group by intent and suggest new posts or sections.

Slipping posts

Compare clicks per day for the last 7 days against the 28- and 90-day rates. Ignore the last 3 days because of reporting lag. Which pages are declining, and what in them is likely out of date?

Ship it

Apply the top three changes and save them with update_post as drafts.

One habit: ask it to show the numbers behind every recommendation. Models sometimes round "average position 8.6" into "ranking on page one". With the raw numbers in the answer, you catch that instantly.

I packaged these as a reusable Claude skill. The full SKILL.md is here.

Which route to pick

  • Many sites, deep audits, URL inspection: self-hosted open-source server.
  • Agency with GA4, ads and GSC together: a hosted multi-source connector.
  • One product blog, and you want the agent to fix what it finds: keep reading and publishing on the same server. That's route C, or build your own with the patterns above.

The longer comparison is here. If you've built a GSC MCP server yourself, I'd like to hear how you handled the row-limit/sorting question.

Top comments (0)