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:
Disclosure: route C below is my product, Blogizi. Routes A and B don't involve it.
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 }, ...]
The quirks that matter for agents:
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.webmasters.readonly.
There are several on GitHub. The setup is roughly the same for all of them:
claude mcp add gsc -- npx some-gsc-mcp-server --credentials ./service-account.json
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.
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.
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"
You click "Connect Google" in the dashboard once and pick the property. No Cloud project. Next, how it works.
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,
});
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.
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 * * *" }] }
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:
get_search_performance 5–10 times with different windows. That shouldn't be 30 Google API calls.
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
});
}
);
Design notes:
days is a union of literals, not a number.range and lastSyncedAt.
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);
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.
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.
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.