Google Search Console + MCP: 3 ways to give Claude your search data (and how I built one) A developer built a Google Search Console MCP server that lets Claude query cached search performance data (clicks, impressions, CTR, position, top queries and pages) through a single get_search_performance tool. The implementation uses the searchanalytics.query endpoint with webmasters.readonly OAuth scopes, stores refresh tokens encrypted at rest, and runs a daily cron that syncs date, query and page dimensions across 7-, 28- and 90-day windows so agents avoid repeated live Google API calls. The writeup also compares three routes to giving an agent GSC access: a self-hosted open-source MCP server, a paid hosted marketing-data endpoint, and the author's own Blogizi server, which pairs search reads with post updates in one project and URL space. 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 https://blogizi.com/?ref=devto-gsc-mcp . Routes A and B don't involve it. Nearly everything useful comes from one endpoint, searchanalytics.query : js 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. js 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: js 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 https://blogizi.com/blog/claude-seo-workflow?ref=devto-gsc-mcp . The longer comparison is here https://blogizi.com/blog/google-search-console-mcp?ref=devto-gsc-mcp . If you've built a GSC MCP server yourself, I'd like to hear how you handled the row-limit/sorting question.