{"slug": "google-search-console-mcp-3-ways-to-give-claude-your-search-data-and-how-i-built", "title": "Google Search Console + MCP: 3 ways to give Claude your search data (and how I built one)", "summary": "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.", "body_md": "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.\n\nThe missing piece is access. Google doesn't ship an official Search Console MCP server, so you have to wire one up. This post covers:\n\nDisclosure: route C below is my product, [Blogizi](https://blogizi.com/?ref=devto-gsc-mcp). Routes A and B don't involve it.\n\nNearly everything useful comes from one endpoint, `searchanalytics.query`:\n\n``` js\nimport { google } from \"googleapis\";\n\nconst searchconsole = google.searchconsole({ version: \"v1\", auth: oauthClient });\n\nconst res = await searchconsole.searchanalytics.query({\n  siteUrl: \"sc-domain:example.com\", // or \"https://example.com/\"\n  requestBody: {\n    startDate: \"2026-09-11\",\n    endDate: \"2026-10-08\",\n    dimensions: [\"query\"],          // \"page\", \"date\", \"country\", \"device\"...\n    rowLimit: 1000,                 // max 25,000 per request\n    dataState: \"final\",\n  },\n});\n\n// res.data.rows: [{ keys: [\"yaml frontmatter\"], clicks: 4, impressions: 183, ctr: 0.0219, position: 8.4 }, ...]\n```\n\nThe quirks that matter for agents:\n\n`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`.\nThere are several on GitHub. The setup is roughly the same for all of them:\n\n```\nclaude mcp add gsc -- npx some-gsc-mcp-server --credentials ./service-account.json\n```\n\n**Good:** free, full API access (URL inspection, any dimension, many sites), and your data stays on your machine.\n\n**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.\n\nSeveral 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.\n\nThis 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.\n\n```\nclaude mcp add --transport http blogizi https://blogizi.com/api/mcp \\\n  --header \"Authorization: Bearer $BLOGIZI_API_KEY\"\n```\n\nYou click \"Connect Google\" in the dashboard once and pick the property. No Cloud project. Next, how it works.\n\n``` js\nconst GSC_SCOPES = [\n  \"https://www.googleapis.com/auth/webmasters.readonly\",\n  \"openid\",\n  \"email\",\n];\n\nclient.generateAuthUrl({\n  access_type: \"offline\", // we need a refresh token\n  prompt: \"consent\",      // without this, Google only returns it on the first consent\n  scope: GSC_SCOPES,\n  state,\n});\n```\n\nThe 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.\n\nThe MCP tool does **not** call Google live. A daily cron syncs each bound property into snapshots, and the tool reads those:\n\n```\n{ \"crons\": [{ \"path\": \"/api/cron/gsc-sync\", \"schedule\": \"0 6 * * *\" }] }\n```\n\nThe sync runs `date`, `query` and `page` dimensions in parallel, for 7-, 28- and 90-day windows. A manual refresh has a 5-minute cooldown.\n\nWhy cache:\n\n`get_search_performance` 5–10 times with different windows. That shouldn't be 30 Google API calls.\n\n```\nserver.registerTool(\n  \"get_search_performance\",\n  {\n    title: \"Get search performance\",\n    description:\n      \"Read cached Google Search Console performance for a Blogizi project (clicks, impressions, CTR, position, top queries/pages).\",\n    inputSchema: z.object({\n      projectSlug: z.string().optional(),\n      days: z.union([z.literal(7), z.literal(28), z.literal(90)]).optional(),\n    }),\n  },\n  async (args, ctx) => {\n    const project = await resolveProjectFromAuth(ctx.http?.authInfo, args.projectSlug);\n    const data = await getProjectSearchConsoleSummary(project, args.days ?? 28);\n    return jsonResult({\n      property: data.property,\n      range: data.range,\n      lastSyncedAt: data.lastSyncedAt,\n      totals: data.totals,\n      queries: data.queries, // top 25\n      pages: data.pages,     // top 25\n    });\n  }\n);\n```\n\nDesign notes:\n\n`days` is a union of literals, not a number.`range` and `lastSyncedAt`.\nWhile 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.\n\nThe 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.\n\nThe fix is to fetch wide and sort on what you actually care about:\n\n``` js\nconst rows = mapApiRows(res.data.rows) // requested with rowLimit: 1000\n  .sort((a, b) => b.clicks - a.clicks || b.impressions - a.impressions)\n  .slice(0, 50);\n```\n\nGeneral 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.\n\nThese work with any of the three routes.\n\n**Titles that aren't earning clicks**\n\nGet 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.\n\n**Almost on page one**\n\nWhich 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?\n\n**Demand you're not serving**\n\nList queries where the ranking page is my homepage or a loosely related post. Group by intent and suggest new posts or sections.\n\n**Slipping posts**\n\nCompare 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?\n\n**Ship it**\n\nApply the top three changes and save them with update_post as drafts.\n\nOne 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.\n\nI packaged these as a reusable Claude skill. The full `SKILL.md` is [here](https://blogizi.com/blog/claude-seo-workflow?ref=devto-gsc-mcp).\n\nThe 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.", "url": "https://wpnews.pro/news/google-search-console-mcp-3-ways-to-give-claude-your-search-data-and-how-i-built", "canonical_source": "https://dev.to/rudolfsrijkuris/google-search-console-mcp-3-ways-to-give-claude-your-search-data-and-how-i-built-one-3bam", "published_at": "2026-10-11 10:09:33+00:00", "updated_at": "2026-10-11 10:21:33.317389+00:00", "lang": "en", "topics": ["agent-protocols", "ai-agents", "ai-tools", "developer-tools"], "entities": ["Google Search Console", "Claude", "Blogizi", "Google", "Model Context Protocol", "googleapis"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/google-search-console-mcp-3-ways-to-give-claude-your-search-data-and-how-i-built", "markdown": "https://wpnews.pro/news/google-search-console-mcp-3-ways-to-give-claude-your-search-data-and-how-i-built.md", "text": "https://wpnews.pro/news/google-search-console-mcp-3-ways-to-give-claude-your-search-data-and-how-i-built.txt", "jsonld": "https://wpnews.pro/news/google-search-console-mcp-3-ways-to-give-claude-your-search-data-and-how-i-built.jsonld"}}